diff --git a/.github/workflows/status_embed.yaml b/.github/workflows/status_embed.yaml index ac808dc..fb19079 100644 --- a/.github/workflows/status_embed.yaml +++ b/.github/workflows/status_embed.yaml @@ -3,7 +3,7 @@ name: Status Embed on: workflow_run: workflows: - - Lint + - Validation types: - completed @@ -15,7 +15,7 @@ jobs: steps: # A workflow_run event does not contain all the information # we need for a PR embed. That's why we upload an artifact - # with that information in the Lint workflow. + # with that information in the Validation workflow. - name: Get Pull Request Information id: pr_info if: github.event.workflow_run.event == 'pull_request' diff --git a/.github/workflows/lint.yaml b/.github/workflows/validation.yaml similarity index 87% rename from .github/workflows/lint.yaml rename to .github/workflows/validation.yaml index 40efbcb..9e2a54b 100644 --- a/.github/workflows/lint.yaml +++ b/.github/workflows/validation.yaml @@ -1,4 +1,4 @@ -name: Lint +name: Validation on: push: @@ -20,8 +20,8 @@ env: PRE_COMMIT_HOME: ${{ github.workspace }}/.cache/pre-commit-cache jobs: - lint: - name: Lint + validation: + name: Validation runs-on: ubuntu-latest steps: @@ -80,6 +80,17 @@ jobs: [flake8] %(code)s: %(text)s' \ --extend-exclude '.cache'" + # Run all of the unit tests and generate coverage report with + # coverage.py + - name: Run tests and generate coverage report + run: pytest --cov --disable-warnings -q + + # Publish test coverage report to coveralls.io, print a + # "job" link in the output of the GitHub Action + - name: Publish coverage report to coveralls.io + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: coveralls --service=github # Prepare the Pull Request Payload artifact. If this fails, we # we fail silently using the `continue-on-error` option. It's diff --git a/README.md b/README.md index e824bd8..928cbea 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,9 @@ # Neutron Bot -[![mit](https://img.shields.io/badge/Licensed%20under-GPL-red.svg?style=flat-square)](./LICENSE) ![Python package](https://github.com/Codin-Nerds/Neutron-Bot/workflows/Python%20package/badge.svg) +[![Coverage Status](https://coveralls.io/repos/github/Codin-Nerds/Neutron-Bot/badge.svg?branch=main)](https://coveralls.io/github/Codin-Nerds/Neutron-Bot?branch=main) [![made-with-python](https://img.shields.io/badge/Made%20with-Python%203.9-ffe900.svg?longCache=true&style=flat-square&colorB=00a1ff&logo=python&logoColor=88889e)](https://www.python.org/) +[![license](https://img.shields.io/badge/Licensed%20under-GPL-red.svg?style=flat-square)](./LICENSE) [![Discord](https://img.shields.io/static/v1?label=The%20Codin'%20Nerds&logo=discord&message=%3E600%20members&color=%237289DA&logoColor=white)](https://discord.gg/Dhz9pM7) ## About the bot diff --git a/bot/__main__.py b/bot/__main__.py index 0d91e65..79335dd 100644 --- a/bot/__main__.py +++ b/bot/__main__.py @@ -18,4 +18,4 @@ if __name__ == "__main__": - bot.run(config.TOKEN) + bot.run(config.TOKEN) # pragma: no cover diff --git a/bot/cogs/core/error_handler.py b/bot/cogs/core/error_handler.py index 2913fd0..5e47aa6 100644 --- a/bot/cogs/core/error_handler.py +++ b/bot/cogs/core/error_handler.py @@ -134,7 +134,7 @@ async def handle_json_decode_error(self, ctx: Context, exception: JSONDecodeErro The error occurred on *`line {exception.lineno} column {exception.colno} (char {exception.pos})`* """ ) - if exception.lines: + if hasattr(exception, "lines") and len(exception.lines) >= exception.lineno: msg += textwrap.dedent( f""" ``` diff --git a/bot/cogs/moderation/slowmode.py b/bot/cogs/moderation/slowmode.py index 0cc550c..7820b32 100644 --- a/bot/cogs/moderation/slowmode.py +++ b/bot/cogs/moderation/slowmode.py @@ -25,7 +25,7 @@ async def slow_mode(self, ctx: Context, duration: Duration) -> None: await ctx.channel.edit(slowmode_delay=duration) if duration: - log_msg = f"ser {ctx.author} applied slowmode to #{ctx.channel} for {stringify_duration(duration)}" + log_msg = f"User {ctx.author} applied slowmode to #{ctx.channel} for {stringify_duration(duration)}" msg = f"⏱️ Applied slowmode for this channel, time delay: {stringify_duration(duration)}." else: log_msg = f"User {ctx.author} removed slowmode from #{ctx.channel}" diff --git a/bot/core/bot.py b/bot/core/bot.py index 04a9352..3e1c22d 100644 --- a/bot/core/bot.py +++ b/bot/core/bot.py @@ -25,6 +25,12 @@ def __init__(self, *args, **kwargs) -> None: self.initial_call = True self._ignored_logs = defaultdict(set) + # These attributes will be set later, once the bot starts up + # they can't be set here, because they need asyncio's event loop + # however we still want to define them here to provide type hints + self.http_session: t.Optional[aiohttp.ClientSession] = None + self.db_engine: t.Optional[AsyncEngine] = None + async def load_extensions(self) -> None: """Load all listed cogs.""" for extension in autoload.EXTENSIONS: diff --git a/poetry.lock b/poetry.lock index 07f360c..78ebd61 100644 --- a/poetry.lock +++ b/poetry.lock @@ -46,6 +46,14 @@ dev = ["Cython (>=0.29.20,<0.30.0)", "pytest (>=3.6.0)", "Sphinx (>=1.7.3,<1.8.0 docs = ["Sphinx (>=1.7.3,<1.8.0)", "sphinxcontrib-asyncio (>=0.2.0,<0.3.0)", "sphinx-rtd-theme (>=0.2.4,<0.3.0)"] test = ["pycodestyle (>=2.5.0,<2.6.0)", "flake8 (>=3.7.9,<3.8.0)", "uvloop (>=0.14.0,<0.15.0)"] +[[package]] +name = "atomicwrites" +version = "1.4.0" +description = "Atomic file writes." +category = "dev" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" + [[package]] name = "attrs" version = "21.2.0" @@ -92,6 +100,14 @@ python-versions = ">=2.7" docs = ["sphinx", "jaraco.packaging (>=8.2)", "rst.linker (>=1.9)"] testing = ["pytest (>=4.6)", "pytest-flake8", "pytest-cov", "pytest-black (>=0.3.7)", "pytest-mypy", "pytest-checkdocs (>=2.4)", "pytest-enabler (>=1.0.1)"] +[[package]] +name = "certifi" +version = "2021.5.30" +description = "Python package for providing Mozilla's CA Bundle." +category = "dev" +optional = false +python-versions = "*" + [[package]] name = "cfgv" version = "3.3.0" @@ -108,6 +124,17 @@ category = "main" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" +[[package]] +name = "charset-normalizer" +version = "2.0.4" +description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." +category = "dev" +optional = false +python-versions = ">=3.5.0" + +[package.extras] +unicode_backport = ["unicodedata2"] + [[package]] name = "colorama" version = "0.4.4" @@ -116,6 +143,33 @@ category = "main" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" +[[package]] +name = "coverage" +version = "5.5" +description = "Code coverage measurement for Python" +category = "dev" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, <4" + +[package.extras] +toml = ["toml"] + +[[package]] +name = "coveralls" +version = "3.2.0" +description = "Show coverage stats online via coveralls.io" +category = "dev" +optional = false +python-versions = ">= 3.5" + +[package.dependencies] +coverage = ">=4.1,<6.0" +docopt = ">=0.6.1" +requests = ">=1.0.0" + +[package.extras] +yaml = ["PyYAML (>=3.10)"] + [[package]] name = "decorator" version = "5.0.9" @@ -179,6 +233,14 @@ category = "dev" optional = false python-versions = "*" +[[package]] +name = "docopt" +version = "0.6.2" +description = "Pythonic argument parser, that will make you smile" +category = "dev" +optional = false +python-versions = "*" + [[package]] name = "filelock" version = "3.0.12" @@ -256,6 +318,14 @@ category = "main" optional = false python-versions = ">=3.5" +[[package]] +name = "iniconfig" +version = "1.1.1" +description = "iniconfig: brain-dead simple config-ini parsing" +category = "dev" +optional = false +python-versions = "*" + [[package]] name = "ipython" version = "7.26.0" @@ -377,6 +447,17 @@ category = "main" optional = false python-versions = ">=3.5" +[[package]] +name = "packaging" +version = "21.0" +description = "Core utilities for Python packages" +category = "dev" +optional = false +python-versions = ">=3.6" + +[package.dependencies] +pyparsing = ">=2.0.2" + [[package]] name = "parso" version = "0.8.2" @@ -420,6 +501,17 @@ python-versions = ">=3.6" docs = ["Sphinx (>=4)", "furo (>=2021.7.5b38)", "proselint (>=0.10.2)", "sphinx-autodoc-typehints (>=1.12)"] test = ["appdirs (==1.4.4)", "pytest (>=6)", "pytest-cov (>=2.7)", "pytest-mock (>=3.6)"] +[[package]] +name = "pluggy" +version = "0.13.1" +description = "plugin and hook calling mechanisms for python" +category = "dev" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" + +[package.extras] +dev = ["pre-commit", "tox"] + [[package]] name = "pre-commit" version = "2.13.0" @@ -466,6 +558,14 @@ category = "dev" optional = false python-versions = "*" +[[package]] +name = "py" +version = "1.10.0" +description = "library with cross-python path, ini-parsing, io, code, log facilities" +category = "dev" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" + [[package]] name = "pycodestyle" version = "2.7.0" @@ -490,6 +590,51 @@ category = "dev" optional = false python-versions = ">=3.5" +[[package]] +name = "pyparsing" +version = "2.4.7" +description = "Python parsing module" +category = "dev" +optional = false +python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" + +[[package]] +name = "pytest" +version = "6.2.4" +description = "pytest: simple powerful testing with Python" +category = "dev" +optional = false +python-versions = ">=3.6" + +[package.dependencies] +atomicwrites = {version = ">=1.0", markers = "sys_platform == \"win32\""} +attrs = ">=19.2.0" +colorama = {version = "*", markers = "sys_platform == \"win32\""} +iniconfig = "*" +packaging = "*" +pluggy = ">=0.12,<1.0.0a1" +py = ">=1.8.2" +toml = "*" + +[package.extras] +testing = ["argcomplete", "hypothesis (>=3.56)", "mock", "nose", "requests", "xmlschema"] + +[[package]] +name = "pytest-cov" +version = "2.12.1" +description = "Pytest plugin for measuring coverage." +category = "dev" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" + +[package.dependencies] +coverage = ">=5.2.1" +pytest = ">=4.6" +toml = "*" + +[package.extras] +testing = ["fields", "hunter", "process-tests", "six", "pytest-xdist", "virtualenv"] + [[package]] name = "python-dateutil" version = "2.8.2" @@ -509,6 +654,24 @@ category = "dev" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*" +[[package]] +name = "requests" +version = "2.26.0" +description = "Python HTTP for Humans." +category = "dev" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*" + +[package.dependencies] +certifi = ">=2017.4.17" +charset-normalizer = {version = ">=2.0.0,<2.1.0", markers = "python_version >= \"3\""} +idna = {version = ">=2.5,<4", markers = "python_version >= \"3\""} +urllib3 = ">=1.21.1,<1.27" + +[package.extras] +socks = ["PySocks (>=1.5.6,!=1.5.7)", "win-inet-pton"] +use_chardet_on_py3 = ["chardet (>=3.0.2,<5)"] + [[package]] name = "six" version = "1.16.0" @@ -592,6 +755,19 @@ category = "main" optional = false python-versions = "*" +[[package]] +name = "urllib3" +version = "1.26.6" +description = "HTTP library with thread-safe connection pooling, file post, and more." +category = "dev" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, <4" + +[package.extras] +brotli = ["brotlipy (>=0.6.0)"] +secure = ["pyOpenSSL (>=0.14)", "cryptography (>=1.3.4)", "idna (>=2.0.0)", "certifi", "ipaddress"] +socks = ["PySocks (>=1.5.6,!=1.5.7,<2.0)"] + [[package]] name = "virtualenv" version = "20.7.0" @@ -645,7 +821,7 @@ multidict = ">=4.0" [metadata] lock-version = "1.1" python-versions = "^3.9" -content-hash = "8b7c0484f4f60540598e0a6e5593973ccb599b0ad56ad9c659be3db5a3bb24a2" +content-hash = "7ce84dc1544ad265a490e376fed5f123292f01ce33a5cd1ca03ec36a6d74ba75" [metadata.files] aiohttp = [ @@ -712,6 +888,10 @@ asyncpg = [ {file = "asyncpg-0.23.0-cp39-cp39-win_amd64.whl", hash = "sha256:ceedd46f569f5efb8b4def3d1dd6a0d85e1a44722608d68aa1d2d0f8693c1bff"}, {file = "asyncpg-0.23.0.tar.gz", hash = "sha256:812dafa4c9e264d430adcc0f5899f0dc5413155a605088af696f952d72d36b5e"}, ] +atomicwrites = [ + {file = "atomicwrites-1.4.0-py2.py3-none-any.whl", hash = "sha256:6d1784dea7c0c8d4a5172b6c620f40b6e4cbfdf96d783691f2e1302a7b88e197"}, + {file = "atomicwrites-1.4.0.tar.gz", hash = "sha256:ae70396ad1a434f9c7046fd2dd196fc04b12f9e91ffb859164193be8b6168a7a"}, +] attrs = [ {file = "attrs-21.2.0-py2.py3-none-any.whl", hash = "sha256:149e90d6d8ac20db7a955ad60cf0e6881a3f20d37096140088356da6c716b0b1"}, {file = "attrs-21.2.0.tar.gz", hash = "sha256:ef6aaac3ca6cd92904cdd0d83f629a15f18053ec84e6432106f7a4d04ae4f5fb"}, @@ -728,6 +908,10 @@ backcall = [ {file = "backports.entry_points_selectable-1.1.0-py2.py3-none-any.whl", hash = "sha256:a6d9a871cde5e15b4c4a53e3d43ba890cc6861ec1332c9c2428c92f977192acc"}, {file = "backports.entry_points_selectable-1.1.0.tar.gz", hash = "sha256:988468260ec1c196dab6ae1149260e2f5472c9110334e5d51adcb77867361f6a"}, ] +certifi = [ + {file = "certifi-2021.5.30-py2.py3-none-any.whl", hash = "sha256:50b1e4f8446b06f41be7dd6338db18e0990601dce795c2b1686458aa7e8fa7d8"}, + {file = "certifi-2021.5.30.tar.gz", hash = "sha256:2bbf76fd432960138b3ef6dda3dde0544f27cbf8546c458e60baf371917ba9ee"}, +] cfgv = [ {file = "cfgv-3.3.0-py2.py3-none-any.whl", hash = "sha256:b449c9c6118fe8cca7fa5e00b9ec60ba08145d281d52164230a69211c5d597a1"}, {file = "cfgv-3.3.0.tar.gz", hash = "sha256:9e600479b3b99e8af981ecdfc80a0296104ee610cab48a5ae4ffd0b668650eb1"}, @@ -736,10 +920,72 @@ chardet = [ {file = "chardet-4.0.0-py2.py3-none-any.whl", hash = "sha256:f864054d66fd9118f2e67044ac8981a54775ec5b67aed0441892edb553d21da5"}, {file = "chardet-4.0.0.tar.gz", hash = "sha256:0d6f53a15db4120f2b08c94f11e7d93d2c911ee118b6b30a04ec3ee8310179fa"}, ] +charset-normalizer = [ + {file = "charset-normalizer-2.0.4.tar.gz", hash = "sha256:f23667ebe1084be45f6ae0538e4a5a865206544097e4e8bbcacf42cd02a348f3"}, + {file = "charset_normalizer-2.0.4-py3-none-any.whl", hash = "sha256:0c8911edd15d19223366a194a513099a302055a962bca2cec0f54b8b63175d8b"}, +] colorama = [ {file = "colorama-0.4.4-py2.py3-none-any.whl", hash = "sha256:9f47eda37229f68eee03b24b9748937c7dc3868f906e8ba69fbcbdd3bc5dc3e2"}, {file = "colorama-0.4.4.tar.gz", hash = "sha256:5941b2b48a20143d2267e95b1c2a7603ce057ee39fd88e7329b0c292aa16869b"}, ] +coverage = [ + {file = "coverage-5.5-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:b6d534e4b2ab35c9f93f46229363e17f63c53ad01330df9f2d6bd1187e5eaacf"}, + {file = "coverage-5.5-cp27-cp27m-manylinux1_i686.whl", hash = "sha256:b7895207b4c843c76a25ab8c1e866261bcfe27bfaa20c192de5190121770672b"}, + {file = "coverage-5.5-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:c2723d347ab06e7ddad1a58b2a821218239249a9e4365eaff6649d31180c1669"}, + {file = "coverage-5.5-cp27-cp27m-manylinux2010_i686.whl", hash = "sha256:900fbf7759501bc7807fd6638c947d7a831fc9fdf742dc10f02956ff7220fa90"}, + {file = "coverage-5.5-cp27-cp27m-manylinux2010_x86_64.whl", hash = "sha256:004d1880bed2d97151facef49f08e255a20ceb6f9432df75f4eef018fdd5a78c"}, + {file = "coverage-5.5-cp27-cp27m-win32.whl", hash = "sha256:06191eb60f8d8a5bc046f3799f8a07a2d7aefb9504b0209aff0b47298333302a"}, + {file = "coverage-5.5-cp27-cp27m-win_amd64.whl", hash = "sha256:7501140f755b725495941b43347ba8a2777407fc7f250d4f5a7d2a1050ba8e82"}, + {file = "coverage-5.5-cp27-cp27mu-manylinux1_i686.whl", hash = "sha256:372da284cfd642d8e08ef606917846fa2ee350f64994bebfbd3afb0040436905"}, + {file = "coverage-5.5-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:8963a499849a1fc54b35b1c9f162f4108017b2e6db2c46c1bed93a72262ed083"}, + {file = "coverage-5.5-cp27-cp27mu-manylinux2010_i686.whl", hash = "sha256:869a64f53488f40fa5b5b9dcb9e9b2962a66a87dab37790f3fcfb5144b996ef5"}, + {file = "coverage-5.5-cp27-cp27mu-manylinux2010_x86_64.whl", hash = "sha256:4a7697d8cb0f27399b0e393c0b90f0f1e40c82023ea4d45d22bce7032a5d7b81"}, + {file = "coverage-5.5-cp310-cp310-macosx_10_14_x86_64.whl", hash = "sha256:8d0a0725ad7c1a0bcd8d1b437e191107d457e2ec1084b9f190630a4fb1af78e6"}, + {file = "coverage-5.5-cp310-cp310-manylinux1_x86_64.whl", hash = "sha256:51cb9476a3987c8967ebab3f0fe144819781fca264f57f89760037a2ea191cb0"}, + {file = "coverage-5.5-cp310-cp310-win_amd64.whl", hash = "sha256:c0891a6a97b09c1f3e073a890514d5012eb256845c451bd48f7968ef939bf4ae"}, + {file = "coverage-5.5-cp35-cp35m-macosx_10_9_x86_64.whl", hash = "sha256:3487286bc29a5aa4b93a072e9592f22254291ce96a9fbc5251f566b6b7343cdb"}, + {file = "coverage-5.5-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:deee1077aae10d8fa88cb02c845cfba9b62c55e1183f52f6ae6a2df6a2187160"}, + {file = "coverage-5.5-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:f11642dddbb0253cc8853254301b51390ba0081750a8ac03f20ea8103f0c56b6"}, + {file = "coverage-5.5-cp35-cp35m-manylinux2010_i686.whl", hash = "sha256:6c90e11318f0d3c436a42409f2749ee1a115cd8b067d7f14c148f1ce5574d701"}, + {file = "coverage-5.5-cp35-cp35m-manylinux2010_x86_64.whl", hash = "sha256:30c77c1dc9f253283e34c27935fded5015f7d1abe83bc7821680ac444eaf7793"}, + {file = "coverage-5.5-cp35-cp35m-win32.whl", hash = "sha256:9a1ef3b66e38ef8618ce5fdc7bea3d9f45f3624e2a66295eea5e57966c85909e"}, + {file = "coverage-5.5-cp35-cp35m-win_amd64.whl", hash = "sha256:972c85d205b51e30e59525694670de6a8a89691186012535f9d7dbaa230e42c3"}, + {file = "coverage-5.5-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:af0e781009aaf59e25c5a678122391cb0f345ac0ec272c7961dc5455e1c40066"}, + {file = "coverage-5.5-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:74d881fc777ebb11c63736622b60cb9e4aee5cace591ce274fb69e582a12a61a"}, + {file = "coverage-5.5-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:92b017ce34b68a7d67bd6d117e6d443a9bf63a2ecf8567bb3d8c6c7bc5014465"}, + {file = "coverage-5.5-cp36-cp36m-manylinux2010_i686.whl", hash = "sha256:d636598c8305e1f90b439dbf4f66437de4a5e3c31fdf47ad29542478c8508bbb"}, + {file = "coverage-5.5-cp36-cp36m-manylinux2010_x86_64.whl", hash = "sha256:41179b8a845742d1eb60449bdb2992196e211341818565abded11cfa90efb821"}, + {file = "coverage-5.5-cp36-cp36m-win32.whl", hash = "sha256:040af6c32813fa3eae5305d53f18875bedd079960822ef8ec067a66dd8afcd45"}, + {file = "coverage-5.5-cp36-cp36m-win_amd64.whl", hash = "sha256:5fec2d43a2cc6965edc0bb9e83e1e4b557f76f843a77a2496cbe719583ce8184"}, + {file = "coverage-5.5-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:18ba8bbede96a2c3dde7b868de9dcbd55670690af0988713f0603f037848418a"}, + {file = "coverage-5.5-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:2910f4d36a6a9b4214bb7038d537f015346f413a975d57ca6b43bf23d6563b53"}, + {file = "coverage-5.5-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:f0b278ce10936db1a37e6954e15a3730bea96a0997c26d7fee88e6c396c2086d"}, + {file = "coverage-5.5-cp37-cp37m-manylinux2010_i686.whl", hash = "sha256:796c9c3c79747146ebd278dbe1e5c5c05dd6b10cc3bcb8389dfdf844f3ead638"}, + {file = "coverage-5.5-cp37-cp37m-manylinux2010_x86_64.whl", hash = "sha256:53194af30d5bad77fcba80e23a1441c71abfb3e01192034f8246e0d8f99528f3"}, + {file = "coverage-5.5-cp37-cp37m-win32.whl", hash = "sha256:184a47bbe0aa6400ed2d41d8e9ed868b8205046518c52464fde713ea06e3a74a"}, + {file = "coverage-5.5-cp37-cp37m-win_amd64.whl", hash = "sha256:2949cad1c5208b8298d5686d5a85b66aae46d73eec2c3e08c817dd3513e5848a"}, + {file = "coverage-5.5-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:217658ec7187497e3f3ebd901afdca1af062b42cfe3e0dafea4cced3983739f6"}, + {file = "coverage-5.5-cp38-cp38-manylinux1_i686.whl", hash = "sha256:1aa846f56c3d49205c952d8318e76ccc2ae23303351d9270ab220004c580cfe2"}, + {file = "coverage-5.5-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:24d4a7de75446be83244eabbff746d66b9240ae020ced65d060815fac3423759"}, + {file = "coverage-5.5-cp38-cp38-manylinux2010_i686.whl", hash = "sha256:d1f8bf7b90ba55699b3a5e44930e93ff0189aa27186e96071fac7dd0d06a1873"}, + {file = "coverage-5.5-cp38-cp38-manylinux2010_x86_64.whl", hash = "sha256:970284a88b99673ccb2e4e334cfb38a10aab7cd44f7457564d11898a74b62d0a"}, + {file = "coverage-5.5-cp38-cp38-win32.whl", hash = "sha256:01d84219b5cdbfc8122223b39a954820929497a1cb1422824bb86b07b74594b6"}, + {file = "coverage-5.5-cp38-cp38-win_amd64.whl", hash = "sha256:2e0d881ad471768bf6e6c2bf905d183543f10098e3b3640fc029509530091502"}, + {file = "coverage-5.5-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:d1f9ce122f83b2305592c11d64f181b87153fc2c2bbd3bb4a3dde8303cfb1a6b"}, + {file = "coverage-5.5-cp39-cp39-manylinux1_i686.whl", hash = "sha256:13c4ee887eca0f4c5a247b75398d4114c37882658300e153113dafb1d76de529"}, + {file = "coverage-5.5-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:52596d3d0e8bdf3af43db3e9ba8dcdaac724ba7b5ca3f6358529d56f7a166f8b"}, + {file = "coverage-5.5-cp39-cp39-manylinux2010_i686.whl", hash = "sha256:2cafbbb3af0733db200c9b5f798d18953b1a304d3f86a938367de1567f4b5bff"}, + {file = "coverage-5.5-cp39-cp39-manylinux2010_x86_64.whl", hash = "sha256:44d654437b8ddd9eee7d1eaee28b7219bec228520ff809af170488fd2fed3e2b"}, + {file = "coverage-5.5-cp39-cp39-win32.whl", hash = "sha256:d314ed732c25d29775e84a960c3c60808b682c08d86602ec2c3008e1202e3bb6"}, + {file = "coverage-5.5-cp39-cp39-win_amd64.whl", hash = "sha256:13034c4409db851670bc9acd836243aeee299949bd5673e11844befcb0149f03"}, + {file = "coverage-5.5-pp36-none-any.whl", hash = "sha256:f030f8873312a16414c0d8e1a1ddff2d3235655a2174e3648b4fa66b3f2f1079"}, + {file = "coverage-5.5-pp37-none-any.whl", hash = "sha256:2a3859cb82dcbda1cfd3e6f71c27081d18aa251d20a17d87d26d4cd216fb0af4"}, + {file = "coverage-5.5.tar.gz", hash = "sha256:ebe78fe9a0e874362175b02371bdfbee64d8edc42a044253ddf4ee7d3c15212c"}, +] +coveralls = [ + {file = "coveralls-3.2.0-py2.py3-none-any.whl", hash = "sha256:aedfcc5296b788ebaf8ace8029376e5f102f67c53d1373f2e821515c15b36527"}, + {file = "coveralls-3.2.0.tar.gz", hash = "sha256:15a987d9df877fff44cd81948c5806ffb6eafb757b3443f737888358e96156ee"}, +] decorator = [ {file = "decorator-5.0.9-py3-none-any.whl", hash = "sha256:6e5c199c16f7a9f0e3a61a4a54b3d27e7dad0dbdde92b944426cb20914376323"}, {file = "decorator-5.0.9.tar.gz", hash = "sha256:72ecfba4320a893c53f9706bebb2d55c270c1e51a28789361aa93e4a21319ed5"}, @@ -757,6 +1003,9 @@ distlib = [ {file = "distlib-0.3.2-py2.py3-none-any.whl", hash = "sha256:23e223426b28491b1ced97dc3bbe183027419dfc7982b4fa2f05d5f3ff10711c"}, {file = "distlib-0.3.2.zip", hash = "sha256:106fef6dc37dd8c0e2c0a60d3fca3e77460a48907f335fa28420463a6f799736"}, ] +docopt = [ + {file = "docopt-0.6.2.tar.gz", hash = "sha256:49b3a825280bd66b3aa83585ef59c4a8c82f2c8a522dbe754a8bc8d08c85c491"}, +] filelock = [ {file = "filelock-3.0.12-py3-none-any.whl", hash = "sha256:929b7d63ec5b7d6b71b0fa5ac14e030b3f70b75747cef1b10da9b879fef15836"}, {file = "filelock-3.0.12.tar.gz", hash = "sha256:18d82244ee114f543149c66a6e0c14e9c4f8a1044b5cdaadd0f82159d6a6ff59"}, @@ -832,6 +1081,10 @@ idna = [ {file = "idna-3.2-py3-none-any.whl", hash = "sha256:14475042e284991034cb48e06f6851428fb14c4dc953acd9be9a5e95c7b6dd7a"}, {file = "idna-3.2.tar.gz", hash = "sha256:467fbad99067910785144ce333826c71fb0e63a425657295239737f7ecd125f3"}, ] +iniconfig = [ + {file = "iniconfig-1.1.1-py2.py3-none-any.whl", hash = "sha256:011e24c64b7f47f6ebd835bb12a743f2fbe9a26d4cecaa7f53bc4f35ee9da8b3"}, + {file = "iniconfig-1.1.1.tar.gz", hash = "sha256:bc3af051d7d14b2ee5ef9969666def0cd1a000e121eaea580d4a313df4b37f32"}, +] ipython = [ {file = "ipython-7.26.0-py3-none-any.whl", hash = "sha256:892743b65c21ed72b806a3a602cca408520b3200b89d1924f4b3d2cdb3692362"}, {file = "ipython-7.26.0.tar.gz", hash = "sha256:0cff04bb042800129348701f7bd68a430a844e8fb193979c08f6c99f28bb735e"}, @@ -906,6 +1159,10 @@ nodeenv = [ ordered-set = [ {file = "ordered-set-4.0.2.tar.gz", hash = "sha256:ba93b2df055bca202116ec44b9bead3df33ea63a7d5827ff8e16738b97f33a95"}, ] +packaging = [ + {file = "packaging-21.0-py3-none-any.whl", hash = "sha256:c86254f9220d55e31cc94d69bade760f0847da8000def4dfe1c6b872fd14ff14"}, + {file = "packaging-21.0.tar.gz", hash = "sha256:7dc96269f53a4ccec5c0670940a4281106dd0bb343f47b7471f779df49c2fbe7"}, +] parso = [ {file = "parso-0.8.2-py2.py3-none-any.whl", hash = "sha256:a8c4922db71e4fdb90e0d0bc6e50f9b273d3397925e5e60a717e719201778d22"}, {file = "parso-0.8.2.tar.gz", hash = "sha256:12b83492c6239ce32ff5eed6d3639d6a536170723c6f3f1506869f1ace413398"}, @@ -922,6 +1179,10 @@ platformdirs = [ {file = "platformdirs-2.2.0-py3-none-any.whl", hash = "sha256:4666d822218db6a262bdfdc9c39d21f23b4cfdb08af331a81e92751daf6c866c"}, {file = "platformdirs-2.2.0.tar.gz", hash = "sha256:632daad3ab546bd8e6af0537d09805cec458dce201bccfe23012df73332e181e"}, ] +pluggy = [ + {file = "pluggy-0.13.1-py2.py3-none-any.whl", hash = "sha256:966c145cd83c96502c3c3868f50408687b38434af77734af1e9ca461a4081d2d"}, + {file = "pluggy-0.13.1.tar.gz", hash = "sha256:15b2acde666561e1298d71b523007ed7364de07029219b604cf808bfa1c765b0"}, +] pre-commit = [ {file = "pre_commit-2.13.0-py2.py3-none-any.whl", hash = "sha256:b679d0fddd5b9d6d98783ae5f10fd0c4c59954f375b70a58cbe1ce9bcf9809a4"}, {file = "pre_commit-2.13.0.tar.gz", hash = "sha256:764972c60693dc668ba8e86eb29654ec3144501310f7198742a767bec385a378"}, @@ -964,6 +1225,10 @@ ptyprocess = [ {file = "ptyprocess-0.7.0-py2.py3-none-any.whl", hash = "sha256:4b41f3967fce3af57cc7e94b888626c18bf37a083e3651ca8feeb66d492fef35"}, {file = "ptyprocess-0.7.0.tar.gz", hash = "sha256:5c5d0a3b48ceee0b48485e0c26037c0acd7d29765ca3fbb5cb3831d347423220"}, ] +py = [ + {file = "py-1.10.0-py2.py3-none-any.whl", hash = "sha256:3b80836aa6d1feeaa108e046da6423ab8f6ceda6468545ae8d02d9d58d18818a"}, + {file = "py-1.10.0.tar.gz", hash = "sha256:21b81bda15b66ef5e1a777a21c4dcd9c20ad3efd0b3f817e7a809035269e1bd3"}, +] pycodestyle = [ {file = "pycodestyle-2.7.0-py2.py3-none-any.whl", hash = "sha256:514f76d918fcc0b55c6680472f0a37970994e07bbb80725808c17089be302068"}, {file = "pycodestyle-2.7.0.tar.gz", hash = "sha256:c389c1d06bf7904078ca03399a4816f974a1d590090fecea0c63ec26ebaf1cef"}, @@ -976,6 +1241,18 @@ pygments = [ {file = "Pygments-2.9.0-py3-none-any.whl", hash = "sha256:d66e804411278594d764fc69ec36ec13d9ae9147193a1740cd34d272ca383b8e"}, {file = "Pygments-2.9.0.tar.gz", hash = "sha256:a18f47b506a429f6f4b9df81bb02beab9ca21d0a5fee38ed15aef65f0545519f"}, ] +pyparsing = [ + {file = "pyparsing-2.4.7-py2.py3-none-any.whl", hash = "sha256:ef9d7589ef3c200abe66653d3f1ab1033c3c419ae9b9bdb1240a85b024efc88b"}, + {file = "pyparsing-2.4.7.tar.gz", hash = "sha256:c203ec8783bf771a155b207279b9bccb8dea02d8f0c9e5f8ead507bc3246ecc1"}, +] +pytest = [ + {file = "pytest-6.2.4-py3-none-any.whl", hash = "sha256:91ef2131a9bd6be8f76f1f08eac5c5317221d6ad1e143ae03894b862e8976890"}, + {file = "pytest-6.2.4.tar.gz", hash = "sha256:50bcad0a0b9c5a72c8e4e7c9855a3ad496ca6a881a3641b4260605450772c54b"}, +] +pytest-cov = [ + {file = "pytest-cov-2.12.1.tar.gz", hash = "sha256:261ceeb8c227b726249b376b8526b600f38667ee314f910353fa318caa01f4d7"}, + {file = "pytest_cov-2.12.1-py2.py3-none-any.whl", hash = "sha256:261bb9e47e65bd099c89c3edf92972865210c36813f80ede5277dceb77a4a62a"}, +] python-dateutil = [ {file = "python-dateutil-2.8.2.tar.gz", hash = "sha256:0123cacc1627ae19ddf3c27a5de5bd67ee4586fbdd6440d9748f8abb483d3e86"}, {file = "python_dateutil-2.8.2-py2.py3-none-any.whl", hash = "sha256:961d03dc3453ebbc59dbdea9e4e11c5651520a876d0f4db161e8674aae935da9"}, @@ -1011,6 +1288,10 @@ pyyaml = [ {file = "PyYAML-5.4.1-cp39-cp39-win_amd64.whl", hash = "sha256:c20cfa2d49991c8b4147af39859b167664f2ad4561704ee74c1de03318e898db"}, {file = "PyYAML-5.4.1.tar.gz", hash = "sha256:607774cbba28732bfa802b54baa7484215f530991055bb562efbed5b2f20a45e"}, ] +requests = [ + {file = "requests-2.26.0-py2.py3-none-any.whl", hash = "sha256:6c1246513ecd5ecd4528a0906f910e8f0f9c6b8ec72030dc9fd154dc1a6efd24"}, + {file = "requests-2.26.0.tar.gz", hash = "sha256:b8aa58f8cf793ffd8782d3d8cb19e66ef36f7aba4353eec859e74678b01b07a7"}, +] six = [ {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"}, {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"}, @@ -1064,6 +1345,10 @@ typing-extensions = [ {file = "typing_extensions-3.10.0.0-py3-none-any.whl", hash = "sha256:779383f6086d90c99ae41cf0ff39aac8a7937a9283ce0a414e5dd782f4c94a84"}, {file = "typing_extensions-3.10.0.0.tar.gz", hash = "sha256:50b6f157849174217d0656f99dc82fe932884fb250826c18350e159ec6cdf342"}, ] +urllib3 = [ + {file = "urllib3-1.26.6-py2.py3-none-any.whl", hash = "sha256:39fb8672126159acb139a7718dd10806104dec1e2f0f6c88aab05d17df10c8d4"}, + {file = "urllib3-1.26.6.tar.gz", hash = "sha256:f57b4c16c62fa2760b7e3d97c35b255512fb6b59a259730f36ba32ce9f8e342f"}, +] virtualenv = [ {file = "virtualenv-20.7.0-py2.py3-none-any.whl", hash = "sha256:fdfdaaf0979ac03ae7f76d5224a05b58165f3c804f8aa633f3dd6f22fbd435d5"}, {file = "virtualenv-20.7.0.tar.gz", hash = "sha256:97066a978431ec096d163e72771df5357c5c898ffdd587048f45e0aecc228094"}, diff --git a/pyproject.toml b/pyproject.toml index d3ca85a..f8dbe12 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,6 +24,10 @@ flake8-import-order = "^0.18.1" ipython = "^7.26.0" pre-commit = "^2.13.0" autopep8 = "^1.5.7" +coverage = "^5.5" +pytest = "^6.2.4" +pytest-cov = "^2.12.1" +coveralls = "^3.2.0" [build-system] requires = ["poetry-core>=1.0.0"] @@ -33,3 +37,12 @@ build-backend = "poetry.core.masonry.api" start = "python -m bot" lint = "pre-commit run --all-files" precommit = "pre-commit install" +test = "pytest" +test-cov = "pytest --cov-report= --cov" +cov-report = "coverage report" +cov-report-html = "coverage html" + +[tool.coverage.run] +branch = true +source_pkgs = ["bot"] +source = ["tests"] diff --git a/tests/README.md b/tests/README.md new file mode 100644 index 0000000..e622adc --- /dev/null +++ b/tests/README.md @@ -0,0 +1,153 @@ +# Testing the bot + +The bot has many moving parts and without tests it would be almost impossible to ensure that introducing some feature won't break something else. Even if the newly introduced feature is fully tried out manually, people aren't perfect and often don't realize that the change in 1 place could affect some code elsewhere, causing to problems in completely unrelated commands/functions. Since simply running the bot won't actually run these functions, but merely compile the files that they're in, many errors often go undiscovered by simply running the bot and testing the new feature. + +Unit tests are the best way to avoid this problem. They simply run automated tests that do the intensive and repetitive work of checking and making sure that each function runs properly and wasn't affected by our changes. + +## Running the tests + +Our unit tests will be ran as a GitHub workflow, for every PR and every commit in the `main` branch. This ensures that all newly introduced changes are indeed properly implemented and aren't breaking existing functions. + +Even though these tests will be ran automatically as workflows, this isn't the suggested way of testing whether your changes aren't breaking them, you should always run the unit tests locally, to avoid any unnecessary failing commits in PRs. To do this, you can simply use taskipy test task with poetry: `poetry run task test`. + +While our unit-tests will run fine with the built-in `unittest` library, we use [`pytest`](https://pytest.org) instead, because of the formatting it provides. Pytest will show full code of the function with an indication of where has the failure ocurred. We also use [`pytest-cov`](https://pypi.org/project/pytest-cov/) for generating coverage reports. + +In order to generate a coverage report when running the tests, you can use `poetry run task test-cov` and to view it, run `poetry run task cov-report`, or to generate HTML website for more detailed coverage report, you can run `poetry run task cov-report-html` which will create `htmlcov` directory, you can open `htmlcov/index.html` in your browser on order to view this website. + +## Mocks + +We often need to pass discord.py specific classes, such as `discord.ext.commands.Context` or `discord.Member`, etc. as arguments for our functions (or any other objects from other libraries, discord.py is just the most common one), but these objects are usually obtained automatically from discord.py as the bot is working. Since we need to perform these tests independently of each other, we shouldn't rely on objects generated by external code. Relying on external code is bad, because then we wouldn't know if the test failure was caused by a failure on our side (something wrong with our function), or something external (such problem in discord.py library, or some other function that we use to generate this object). Since we can't use this external means of obtaining these objects, we instead "simulate" these objects with "fake" objects, that act similarly to the real ones, these objects are called "mocks". + +These mocks come with the `unittest` library, specifically the [`unittest-mock`](https://docs.python.org/3/library/unittest.mock.html) module from python standard library. + +Since our functions often rely on objects of same type, we have our commonly used custom definitions of these discord.py objects in [`tests/dpy_mocks.py`](/tests/dpy_mocks.py) that you can import and use from the individual test files. + +Here is a simple example of a test that's using these mocks: +```py +import asyncio +import unittest + +from bot.cogs.moderation.slowmode import Slowmode +from tests.dpy_mocks import MockBot, MockContext, MockTextChannel + + +class SlowmodeCogTests(unittest.TestCase): + def setup(self) -> None: + self.bot = MockBot() + self.cog = Slowmode(self.bot) + self.context = MockContext() + + def test_slowmode_sends_message(self): + """Ensure that slow_mode command sends the expected message to the affected channel.""" + # Define new slowmode delay time that should be set for our text channel + time = 10 # seconds + + # Create a mocked text channel to represent the channel that should have + # it's slowmode delay changed by the slow_mode command + text_channel = MockTextChannel(name="my-channel", slowmode_delay=1) + + # Set channel attribute for our mocked context, which is accessed from + # context in the slow_mode command callback function + self.context.channel = text_channel + + # Run the slow_mode command callback function (we use asyncio.run, + # because we are in non-async function, so we can't use await here.) + asyncio.run(self.cog.slow_mode.callback(self.cog, self.context, time)) + + # Perform our assert check (this determines whether this test succeeded or failed) + # this tests whether the context mock had `send` function ran with expected + # message attribute + self.context.send.assert_called_once_with(f"⏱️ Applied slowmode for this channel, time delay: {time} seconds.") + + # Sets the mock back to it's original state (without the knowledge that send method + # has been called with any attributes) + self.context.reset_mock() +``` + +## Changing/Writing Unit Tests + +During the development of any new features, it is very common to change the way functions behave, or to remove or write new functions entirely. This will inevitably break the tests, or if it's just something new, it will decrease our test code coverage (the amount of tested code we have in the code-base), we always want to keep this coverage number as high as possible, so that every bit of code will be ran during testing. Because of this, you will likely often have to write new tests or change existing ones, and for that, you need a basic understanding of doing this. + +For our testing, we use the [`unittest`](https://docs.python.org/3/library/unittest.html) module from python standard library. + +When writing new tests, it is important to make sure that the test runs independently of any other objects. This means that the test you make won't influence any other tests, and also that the test won't fail because of some external problem, even though there's no actual problem in the code we're testing. You should always try to only test a single aspect of the code in a single test function, rather than making big test functions that test everything. This helps because we can very quickly identify which test case failed, and when it was only testing a single aspect, we immediately know that it's that aspect that's not working, whereas with a very big function that tests everything about given code, we only know that there is some aspect that failed within that tested code, but we don't immediately know what exactly has failed. + +### Naming conventions + +Unit testing is a very powerful way to quickly identify exactly what aspect of our tested code is failing, however this is only possible with a good naming of the testing methods and their docstrings. By giving a testing method a good and immediately understandable name that explains what it tests, we can instantly know what happened, whereas with a name like `test_lock_command` we only really know that there is some issue with the lock command, but nothing else, compare that to `test_lock_denies_send_permissions`, when a test like this would fail, we know that the lock command didn't actually change the permissions within a channel and didn't manage to actually lock a given channel. + +However method names often aren't sufficient to precisely describe what are you trying to test exactly. Because of this, we can use docstrings to help us describe what's being tested in a more understandable and detailed way. We should try to keep this docstring on a single line, because `unittest` will actually display these docstrings when a test method fails, informing us what exactly went wrong. + +### Independent tests with `self.subTest` + +We often need to run more than one check with single test, we can have multiple test cases where it wouldn't really make sense to split them into multiple test methods. While we should always try and test only one aspect of the code with single test method, we also shouldn't make dozens of test methods for very little details, that will end up being very repetetive. When we encounter such scenario, we should instead use [`subTest`](https://docs.python.org/3/library/unittest.html#distinguishing-test-iterations-using-subtests) context manager. We often use this with a for loop, that's iterating through a list of our test cases, defined in some variable. + +Using `subTest` is important, because it provides a way to separate multiple test cases within a single test method, and if we fail, we will actually see precisely which test case was being ran when we got the failure. + +An example of using `unittest.TestCase.subTest`: +```py + def test_duration_converter_valid_input(self): + """Make sure that Duration converter returns expected amount of seconds for valid inputs.""" + test_values = ( + ("1y", 31536000), + ("1mo", 2678400), + ("1w", 604800), + ("1d", 86400), + ("1h", 3600), + ("1m", 60), + ("1s", 1) + ) + + converter = Duration() + + for duration_string, expected_seconds in test_values: + with self.subTest(duration_string=duration_string, expected_seconds=expected_seconds): + conversion = asyncio.run(converter.convert(self.context, duration_string)) + self.assertEqual(conversion, expected_seconds) +``` + +## Testing asynchronous functions + +You might've noticed that in all of the above examples, the asynchronous functions were tested using `result = asyncio.run(coroutine)`, this is done because we've only used the basic synchronous `unittest.TestCase`, this class is used in most projects to test their synchronous functions, however testing async functions with it is a bit impractical. + +For that reason, for the majority of the tests instead of using the regular `unittest.TestCase`, we are using [`unittest.IsolatedAsyncioTestCase`](https://docs.python.org/3/library/unittest.html#unittest.IsolatedAsyncioTestCase), this class provides us with the same functionality as it's synchronous counterpart, but on top of that, it also adds support for async methods. It introduces `asyncSetUp` and `asyncTearDown` methods on top of the synchronous `setUp` and `tearDown` available in `unittest.TestCase` and adds support for general async methods for test cases. + +Here is a simple example of using this class based on an example above, that was using the synchronous version: +```py +import unittest + +from bot.cogs.moderation.slowmode import Slowmode +from tests.dpy_mocks import MockBot, MockContext, MockTextChannel + + +class SlowmodeCogTests(unittest.IsolatedAsyncioTestCase): + def setup(self) -> None: + self.bot = MockBot() + self.cog = Slowmode(self.bot) + self.context = MockContext() + + async def test_slowmode_change_sends_message(self): + """Ensure that slow_mode command sends the expected message to the affected channel.""" + time = 10 # seconds + + text_channel = MockTextChannel(name="my-channel", slowmode_delay=1) + self.context.channel = text_channel + + # Await the async slow_mode command directly instead of using asyncio.run + await self.cog.slow_mode(self.cog, self.context, time) + + self.context.send.assert_called_once_with(f"⏱️ Applied slowmode for this channel, time delay: {time} seconds.") + self.context.reset_mock() +``` + +## Importance of test coverage + +When writing tests, our main consideration is to increase the code coverage and ensure that ideally, every line of our tested function/class/code part was ran and tested. This is important because python won't report most errors on compile time (when the file is imported) but rather on runtime (when the line of code is actually ran), which means running all lines of code in the tested element ensures that there aren't any such errors that would otherwise go undetected. + +However test coverage isn't everything. This is important to remember, even if the code has 100% code coverage, that doesn't mean it's fully tested nor guaranteed to work. Simply because just running the code doesn't mean we have tested it properly, for example if there is `int(x)` function in the code, and we only tested the function with `x="4"`, this will succeed, however we haven't actually tried to run `x="hi"`, which would raise an exception and our code would fail. Cases like these are quite common because we often don't think to test something so unexpected, however we should always try to test all possible aspects that could occur, even if we've already reached 100% code coverage. + +## Unit Testing vs Integration Testing + +Another restriction to think about when blindly attempting that the code works because our tests run is that, as the name would imply, it was only tested in separate "units", i.e. we only tested separate functions, which means we ensure they work independently, this however doesn't mean that when we actually run our code it will all work together. We should also acknowledge that mocking gives us a lot of flexibility that the real objects may not provide. Mocked objects will usually have any attribute accessible, returning another mock, which means if a function were to access something that shouldn't really be available, but the mock provided it, it would mean that when we actually run our code, it will fail. + +Because this project currently doesn't have any automated integration testing, **it's very important that the bot is still actually ran and tested manually** in addition to running the unit-tests. diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/bot/__init__.py b/tests/bot/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/bot/cogs/__init__.py b/tests/bot/cogs/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/bot/cogs/automod/__init__.py b/tests/bot/cogs/automod/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/bot/cogs/automod/test_filepaste.py b/tests/bot/cogs/automod/test_filepaste.py new file mode 100644 index 0000000..24c401d --- /dev/null +++ b/tests/bot/cogs/automod/test_filepaste.py @@ -0,0 +1,93 @@ +import unittest + +from discord import Permissions + +from bot.cogs.automod.filepaste import FilePaste +from tests.dpy_mocks import MockAttachment, MockBot, MockMember, MockMessage, MockUser + + +async def fake_upload_attachments(*a, **kw) -> str: + """ + Fake upload_attachments function that's being called by the on_message event handler. + We don't want to run the real function because that would try to upload the content + of our mocked attachment to paste.gg which isn't what we're testing here. + + Simply return `http://example.com` as the URL no matter the input parameters. + """ + return "https://example.com" + + +class FilePasteCogTests(unittest.IsolatedAsyncioTestCase): + def setUp(self) -> None: + self.bot = MockBot() + self.cog = FilePaste(self.bot) + + @unittest.mock.patch("bot.cogs.automod.filepaste.upload_attachments", fake_upload_attachments) + async def test_in_dm(self): + """Test that a user in a DM can send any message without it being removed.""" + dm_user = MockUser() + disallowed_attachment = MockAttachment(filename="file_with_disallowed_extension.exe") + message = MockMessage(author=dm_user, attachments=[disallowed_attachment]) + await self.cog.on_message(message) + + @unittest.mock.patch("bot.cogs.automod.filepaste.upload_attachments", fake_upload_attachments) + async def test_regular_member_no_attachments(self): + """Test that a member without manage_messages permissions can post a message without any attachments.""" + regular_member = MockMember() + regular_member.permissions_in.return_value = Permissions(manage_messages=False) + message = MockMessage(author=regular_member, attachments=[]) + await self.cog.on_message(message) + + message.delete.assert_not_awaited() + + @unittest.mock.patch("bot.cogs.automod.filepaste.upload_attachments", fake_upload_attachments) + async def test_regular_member_allowed_extension(self): + """Test that a member without manage_messages permissions can post an attachments with allowed extension.""" + regular_member = MockMember() + regular_member.permissions_in.return_value = Permissions(manage_messages=False) + allowed_attachment = MockAttachment(filename="file_with_disallowed_extension.png") + message = MockMessage(author=regular_member, attachments=[allowed_attachment]) + await self.cog.on_message(message) + + message.delete.assert_not_awaited() + + @unittest.mock.patch("bot.utils.paste_upload.upload_attachments", fake_upload_attachments) + async def test_excepted_member_disallowed_extension(self): + """Test that a member with manage_messages permissions is excepted from posting disallowed attachments.""" + excepted_member = MockMember() + excepted_member.permissions_in.return_value = Permissions(manage_messages=True) + disallowed_attachment = MockAttachment(filename="file_with_disallowed_extension.exe") + message = MockMessage(author=excepted_member, attachments=[disallowed_attachment]) + await self.cog.on_message(message) + + message.delete.assert_not_awaited() + + @unittest.mock.patch("bot.cogs.automod.filepaste.upload_attachments", fake_upload_attachments) + async def test_regular_member_disallowed_extension(self): + """Test that a member without manage_messages permissions have the message removed when posting disallowed attachments.""" + regular_member = MockMember() + regular_member.permissions_in.return_value = Permissions(manage_messages=False) + disallowed_attachment = MockAttachment(filename="file_with_disallowed_extension.exe") + message = MockMessage(author=regular_member, attachments=[disallowed_attachment]) + await self.cog.on_message(message) + + message.delete.assert_awaited_once() + + @unittest.mock.patch("bot.cogs.automod.filepaste.upload_attachments", fake_upload_attachments) + async def test_regular_member_disallowed_extension_message(self): + """Test that a member without manage_messages permissions will get an embed with the link to his uploaded attachments..""" + regular_member = MockMember() + regular_member.permissions_in.return_value = Permissions(manage_messages=False) + disallowed_attachment = MockAttachment(filename="file_with_disallowed_extension.exe") + message = MockMessage(author=regular_member, attachments=[disallowed_attachment]) + await self.cog.on_message(message) + + # Obtain the non-changing URL from fake_upload_attachments function + fake_upload_url = await fake_upload_attachments() + + # Get the value for the auto-upload field within the sent embed + embed = message.channel.send.await_args[1]["embed"] + auto_update_field_value = embed.fields[0].value + + # Ensure that this field's value contained our upload_url + self.assertIn(fake_upload_url, auto_update_field_value) diff --git a/tests/bot/cogs/core/__init__.py b/tests/bot/cogs/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/bot/cogs/core/test_error_handler.py b/tests/bot/cogs/core/test_error_handler.py new file mode 100644 index 0000000..3cfda31 --- /dev/null +++ b/tests/bot/cogs/core/test_error_handler.py @@ -0,0 +1,102 @@ +import unittest +import unittest.mock +from json.decoder import JSONDecodeError + +from discord.ext.commands.errors import CheckFailure, CommandInvokeError, UserInputError + +from bot.cogs.core.error_handler import ErrorHandler +from bot.cogs.utility.embeds import InvalidEmbed +from tests.dpy_mocks import MockBot, MockContext + + +class ErrorHandlerCogTests(unittest.IsolatedAsyncioTestCase): + def setUp(self) -> None: + self.bot = MockBot() + self.cog = ErrorHandler(self.bot) + self.context = MockContext() + + # Avoid problems with `ctx.command.aliases` not being iterable + self.context.command.aliases = [] + + async def test_send_error_embed(self): + """Test that _send_error_embed does actually send an embed message.""" + await self.cog._send_error_embed(self.context, "title", "description") + self.context.send.assert_awaited_once() + + async def test_send_unhandled_embed(self): + """TRest that send_unhandled_embed calls lower level _send_error_embed function.""" + # Replace the actual _send_error_embed with an AsyncMock, so that we can make sure that + # it was actually ran, also running the actual _send_error_embed function would be out + # of scope for this specific test + _send_error_embed = unittest.mock.AsyncMock() + with unittest.mock.patch("bot.cogs.core.error_handler.ErrorHandler._send_error_embed", _send_error_embed): + await self.cog.send_unhandled_embed(self.context, Exception()) + _send_error_embed.assert_awaited_once() + + async def test_proper_handler_used(self): + """Make sure that on_command_errors uses the correct handlers for given exception types.""" + # Define some exception that would clutter the test_cases here as simple variables + exc_json_decode_error = CommandInvokeError(JSONDecodeError(msg="my message", doc="error line", pos=0)) + exc_json_decode_error.__cause__ = exc_json_decode_error.original + + exc_invalid_embed = CommandInvokeError(InvalidEmbed( + discord_code=unittest.mock.Mock(), + status_code=unittest.mock.Mock(), + status_text=unittest.mock.Mock(), + message=unittest.mock.Mock() + )) + exc_invalid_embed.__cause__ = exc_invalid_embed.original + + test_cases = ( + (UserInputError(), "handle_user_input_error"), + (CheckFailure(), "handle_check_failure"), + (exc_json_decode_error, "handle_json_decode_error"), + (exc_invalid_embed, "handle_invalid_embed"), + # This isn't actually an error handler, however there is no + # error handler for unhandled exception, so we instead just + # send the unhandled embed directly, for the purposes of this + # test, this will work fine + (RuntimeError(), "send_unhandled_embed"), + ) + + # Override the handler for given exception so that we can check whether a call was + # made to it, it also prevents from running the actual handler functions, which isn't the + # intention behind this test. + mocked_handler = unittest.mock.AsyncMock() + + for exception, handler_function_name in test_cases: + with unittest.mock.patch(f"bot.cogs.core.error_handler.ErrorHandler.{handler_function_name}", mocked_handler): + with self.subTest(exception=exception, handler_function_name=handler_function_name): + await self.cog.on_command_error(self.context, exception) + mocked_handler.assert_awaited_once() + mocked_handler.reset_mock() + + async def test_error_handlers_send_embed(self): + """Make sure that all error handlers send an embed.""" + # Define some exceptions that would clutter the test_cases here as simple variables + exc_json_decode_error = JSONDecodeError(msg="my message", doc="error line", pos=0) + exc_json_decode_error.lines = unittest.mock.MagicMock() + exc_invalid_embed = InvalidEmbed( + discord_code=unittest.mock.Mock(), + status_code=unittest.mock.Mock(), + status_text=unittest.mock.Mock(), + message=unittest.mock.Mock() + ) + + test_cases = ( + ("handle_user_input_error", self.cog.handle_user_input_error, UserInputError()), + ("handle_check_failure", self.cog.handle_check_failure, CheckFailure()), + ("handle_json_decode_error", self.cog.handle_json_decode_error, exc_json_decode_error), + ("handle_invalid_embed", self.cog.handle_invalid_embed, exc_invalid_embed), + ) + + # Override the _send_error_embed async function so that we can test whether a call was made to it, + # it also prevents from running this function, since that's not the intention behind this test + _send_error_embed = unittest.mock.AsyncMock() + + with unittest.mock.patch("bot.cogs.core.error_handler.ErrorHandler._send_error_embed", _send_error_embed): + for handler_name, handler_function, exception in test_cases: + with self.subTest(msg=f"Make sure {handler_name} sends an embed."): + await handler_function(self.context, exception) + _send_error_embed.assert_awaited_once() + _send_error_embed.reset_mock() diff --git a/tests/bot/cogs/core/test_sudo.py b/tests/bot/cogs/core/test_sudo.py new file mode 100644 index 0000000..8303c3e --- /dev/null +++ b/tests/bot/cogs/core/test_sudo.py @@ -0,0 +1,26 @@ +import unittest + +from discord.ext.commands.errors import NotOwner + +from bot.cogs.core.sudo import Sudo +from tests.dpy_mocks import MockBot, MockContext + + +class SudoCogTests(unittest.IsolatedAsyncioTestCase): + def setUp(self) -> None: + self.bot = MockBot() + self.cog = Sudo(self.bot) + self.context = MockContext() + + async def test_cog_check(self): + """Only members with manage_messages permissions should be allowed to use this cog.""" + + with self.subTest(msg="Test cog_check on bot owner"): + self.bot.is_owner.return_value = True + result = await self.cog.cog_check(self.context) + self.assertEqual(result, True) + + with self.subTest(msg="Test cog_check on non bot owner"): + self.bot.is_owner.return_value = False + with self.assertRaises(NotOwner): + await self.cog.cog_check(self.context) diff --git a/tests/bot/cogs/moderation/__init__.py b/tests/bot/cogs/moderation/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/bot/cogs/moderation/test_slowmode.py b/tests/bot/cogs/moderation/test_slowmode.py new file mode 100644 index 0000000..3b52138 --- /dev/null +++ b/tests/bot/cogs/moderation/test_slowmode.py @@ -0,0 +1,58 @@ +import unittest + +from discord import Permissions +from discord.ext.commands.errors import BadArgument, MissingPermissions + +from bot.cogs.moderation.slowmode import Slowmode +from tests.dpy_mocks import MockBot, MockContext, MockMember, MockTextChannel + + +class SlowmodeCogTests(unittest.IsolatedAsyncioTestCase): + def setUp(self) -> None: + self.bot = MockBot() + self.cog = Slowmode(self.bot) + self.context = MockContext() + self.text_channel = MockTextChannel(name="my-channel", slowmode_delay=1) + self.context.channel = self.text_channel + + async def test_slowmode_changes_time_delay(self): + """Make sure that when the slow_mode command is ran properly, """ + new_time = 10 # seconds + await self.cog.slow_mode(self.cog, self.context, new_time) + self.assertEqual(self.text_channel.slowmode_delay, new_time) + + async def test_slowmode_apply_sends_message(self): + """Ensure that slow_mode command sends the expected message to the affected channel.""" + test_cases = ( + (10, "⏱️ Applied slowmode for this channel, time delay: 10 seconds."), + (0, "💬 Slowmode removed.") + ) + + for slowmode_time, expected_message in test_cases: + with self.subTest(slowmode_time=slowmode_time, expected_message=expected_message): + await self.cog.slow_mode(self.cog, self.context, slowmode_time) + self.context.send.assert_called_once_with(expected_message) + self.context.reset_mock() + + async def test_slowmode_invalid_duration(self): + """Discord only supports slowmode duration of up to 6 hours, make sure we respect that.""" + new_time = 6 * 60 * 60 + 1 # 6 hours (in seconds) + 1 + with self.assertRaises(BadArgument): + await self.cog.slow_mode(self.cog, self.context, new_time) + + async def test_cog_check(self): + """Only members with manage_messages permissions should be allowed to use this cog.""" + authorized_member = MockMember() + authorized_member.permissions_in.return_value = Permissions(manage_channels=True) + unauthorized_member = MockMember() + unauthorized_member.permissions_in.return_value = Permissions(manage_channels=False) + + with self.subTest(test_member=authorized_member, msg="Test cog_check on authorized member"): + self.context.author = authorized_member + result = await self.cog.cog_check(self.context) + self.assertEqual(result, True) + + with self.subTest(test_member=unauthorized_member, msg="Test cog_check on unauthorized member"): + self.context.author = unauthorized_member + with self.assertRaises(MissingPermissions): + await self.cog.cog_check(self.context) diff --git a/tests/bot/cogs/moderation/test_strikes.py b/tests/bot/cogs/moderation/test_strikes.py new file mode 100644 index 0000000..5ffcc61 --- /dev/null +++ b/tests/bot/cogs/moderation/test_strikes.py @@ -0,0 +1,47 @@ +import unittest +import unittest.mock + +from discord import Permissions +from discord.ext.commands.errors import MissingPermissions + +from bot.cogs.moderation.strikes import Strikes +from bot.config import StrikeType +from tests.dpy_mocks import MockBot, MockContext, MockMember, MockUser + + +class StrikesCogTests(unittest.IsolatedAsyncioTestCase): + def setUp(self) -> None: + self.bot = MockBot() + self.cog = Strikes(self.bot) + self.context = MockContext() + + async def test_add(self): + """Test add strike command.""" + mocked_strikes_db = unittest.mock.AsyncMock() + with unittest.mock.patch("bot.cogs.moderation.strikes.StrikesDB", mocked_strikes_db): + await self.cog.add(self.cog, self.context, MockUser(), StrikeType.kick) + mocked_strikes_db.set_strike.assert_awaited_once() + + async def test_remove(self): + """Test add strike command.""" + mocked_strikes_db = unittest.mock.AsyncMock() + with unittest.mock.patch("bot.cogs.moderation.strikes.StrikesDB", mocked_strikes_db): + await self.cog.remove(self.cog, self.context, 1) + mocked_strikes_db.remove_strike.assert_awaited_once() + + async def test_cog_check(self): + """Only members with administrator permissions should be allowed to use this cog.""" + authorized_member = MockMember() + authorized_member.permissions_in.return_value = Permissions(administrator=True) + unauthorized_member = MockMember() + unauthorized_member.permissions_in.return_value = Permissions(administrator=False) + + with self.subTest(test_member=authorized_member, msg="Test cog_check on authorized member"): + self.context.author = authorized_member + result = await self.cog.cog_check(self.context) + self.assertEqual(result, True) + + with self.subTest(test_member=unauthorized_member, msg="Test cog_check on unauthorized member"): + self.context.author = unauthorized_member + with self.assertRaises(MissingPermissions): + await self.cog.cog_check(self.context) diff --git a/tests/dpy_mocks.py b/tests/dpy_mocks.py new file mode 100644 index 0000000..675a9bb --- /dev/null +++ b/tests/dpy_mocks.py @@ -0,0 +1,523 @@ +# This file holds mocked discord.py structures, which will be used within +# tests to provide a way of passing these mocked objects. +import datetime +import itertools +import typing as t +import unittest.mock + +import discord +import discord.ext.commands +import discord.mixins +from aiohttp import ClientSession +from sqlalchemy.ext.asyncio import AsyncEngine + +from bot.__main__ import bot as bot_instance + + +# Discord objects often require `state` argument, this can be a simple `MagicMock`. +mock_state = unittest.mock.MagicMock() + + +class CustomMockMixin: + """ + This class is here to provide common functionality for our mock classes. + + We usually don't want to propagate the same custom mock class for newly + accessed attributes of given class, but rather make new independent mocks, + generally our objects won't have attributes that hold instances of that + same class, so it doesn't make sense to propagate them like this here. + + It provides `spec_asyncs_extend` parameter that allows us to easily + extend the `_spec_asyncs` and the accessed attributes will be returning + `AsyncMock` instances instead of just `Mock` instances. + + The class also provides `discord_id` generator, that will produce a unique + incremental discord id for each mocked discord instance, to avoid collisions. + """ + spec_set = None + spec_asyncs_extend = None + discord_id = itertools.count(0) + + def __init__(self, **kwargs): + super().__init__(spec_set=self.spec_set, **kwargs) + + # Add custom attributes that should be given AsyncMocks in addition + # to the methods added by default from the unittest.mock implementation. + if self.spec_asyncs_extend: + self._spec_asyncs.extend(self.spec_asyncs_extend) + + def _get_child_mock(self, **kwargs): + """ + By default, when we're accessing an attribute of a given mock, it will + default to making another mocked instance of that same type (using that + same custom mock class), we usually don't want this since our objects + generally won't have attributes that hold instances of the parent's class. + + This method overrides the default implementation and generates instances + of simple independent `unittest.mock.Mock` or `unittest.mock.AsyncMock` + objects, rather than `self.__class__` objects. + + This approach also prevents RecursionErrors when defining some attribute + of a mock, which would be making that attribute as an instance of the + original class, that again tries to define this attribute. + """ + new_name = kwargs.get("_new_name") + + # Check if the accessed attribute is defined in the spec list of async attributes + if new_name in self._spec_asyncs: + return unittest.mock.AsyncMock(**kwargs) + + # Mocks can be sealed, in which case we wouldn't want to allow propagation of any + # kind and rather raise an AttributeError, informing that given attr isn't accessible + if self._mock_sealed: + mock_name = self._extract_mock_name() + if "name" in kwargs: + raise AttributeError(f"{mock_name}.{kwargs['name']}") + else: + raise AttributeError(f"{mock_name}()") + + # If we're using magic mocks, and we attempt to access one of the async + # dunder methods (e.g.: __aenter__), automatically return AsyncMock + if issubclass(type(self), unittest.mock.MagicMock): + return unittest.mock.AsyncMock(**kwargs) + + # Propagate any other non-async children as simple `unittest.mock.Mock` instances + # rather than `self.__class__` instances, which is the default behavior + return unittest.mock.Mock(**kwargs) + + +class ColorMixin: + """ + Discord often allows for accessing/setting both 'color' and 'colour' arguments that + correspond to a single value, this class replicates this behavior with the use of + properties, it can then be subclassed in a mock discord object class to apply it. + """ + @property + def color(self) -> discord.Colour: + return self.colour + + @color.setter + def color(self, color: discord.Colour) -> None: + self.colour = color + + +guild_data = { + "id": 734712951621025822, + "name": "Codin' Nerds", + "region": "Europe", + "verification_level": 2, + "default_notifications": 1, + "afk_timeout": 100, + "icon": "icon.png", + "banner": "banner.png", + "mfa_level": 1, + "splash": "splash.png", + "system_channel_id": 464033278631084042, + "description": "test guild mock", + "max_presences": 10_000, + "max_members": 100_000, + "preferred_locale": "UTC", + "owner_id": 306876636526280705, + "afk_channel_id": 464033278631084042, +} +guild_instance = discord.Guild(data=guild_data, state=mock_state) + + +class MockGuild(CustomMockMixin, unittest.mock.Mock, discord.mixins.Hashable): + """A class for creating mocked `discord.Guild` objects.""" + spec_set = guild_instance + + def __init__(self, roles: t.Optional[t.Iterable["MockRole"]] = None, **kwargs): + new_kwargs = {"id": next(self.discord_id), "members": []} + new_kwargs.update(kwargs) + + # Handle `roles` separately, because we always need to add + # a special `@everyone` role + new_kwargs["roles"] = [MockRole(name="@everyone", position=1, id=0)] + if roles: + new_kwargs["roles"].extend(roles) + + super().__init__(**new_kwargs) + + +role_data = {"id": 734712951637934093, "name": "Admin"} +role_instance = discord.Role(guild=guild_instance, state=mock_state, data=role_data) + + +class MockRole(CustomMockMixin, unittest.mock.Mock, ColorMixin, discord.mixins.Hashable): + """A class for creating mocked `discord.Role` objects.""" + spec_set = role_instance + + def __init__(self, **kwargs): + new_kwargs = { + "id": next(self.discord_id), + "name": "MockedRole", + "position": 1, + "colour": discord.Colour(0x000000), + "permissions": discord.Permissions() + } + new_kwargs.update(kwargs) + if "mention" not in new_kwargs: + new_kwargs["mention"] = f"&{new_kwargs['name']}" + + super().__init__(**new_kwargs) + + # Replicate discord's way of passing color/permissions as pure int objects + # and converting them to proper representative objects afterwards. + if isinstance(self.colour, int): + self.colour = discord.Colour(self.colour) + if isinstance(self.permissions, int): + self.permissions = discord.Permissions(self.permissions) + + def __lt__(self, other): + """Position-based comparisons just like in `discord.Role`""" + return self.position < other.position + + def __ge__(self, other): + """Position-based comparisons just like in `discord.Role`""" + return self.position >= other.position + + +user_data = { + "id": 306876636526280705, + "username": "ItsDrike", + "discriminator": 5359, + "avatar": "avatar.png" +} +user_instance = discord.User(data=user_data, state=mock_state) + + +class MockUser(CustomMockMixin, unittest.mock.Mock, ColorMixin, discord.mixins.Hashable): + """A class for creating mocked `discord.User` objects.""" + spec_set = user_instance + + def __init__(self, **kwargs): + new_kwargs = { + "id": next(self.discord_id), + "name": "MockUser", + "bot": False + } + new_kwargs.update(kwargs) + + if "mention" not in new_kwargs: + new_kwargs["mention"] = f"&{new_kwargs['name']}" + super().__init__(**new_kwargs) + + +member_data = {"user": "ItsDrike", "roles": [1]} +member_instance = discord.Member(data=member_data, guild=guild_instance, state=mock_state) + + +class MockMember(CustomMockMixin, unittest.mock.Mock, ColorMixin, discord.mixins.Hashable): + """A class for creating mocked `discord.Member` objects.""" + spec_set = member_instance + + def __init__(self, roles: t.Optional[t.Iterable["MockRole"]] = None, **kwargs): + new_kwargs = { + "id": next(self.discord_id), + "name": "MockedMember", + "pending": False, + "bot": False, + "guild": MockGuild() + } + new_kwargs.update(kwargs) + + if "mention" not in new_kwargs: + new_kwargs["mention"] = f"&{new_kwargs['name']}" + + # Handle `roles` separately, because we always need to add + # a special `@everyone` role + new_kwargs["roles"] = [MockRole(name="@everyone", position=1, id=0)] + if roles: + new_kwargs["roles"].extend(roles) + + super().__init__(**new_kwargs) + + +text_channel_data = { + "id": 734712951872815138, + "type": "TextChannel", + "name": "global-chat", + "parent_id": 734712951872815137, + "topic": "Main discussion channel", + "position": 1, + "nsfw": False, + "last_message_id": 1 +} +text_channel_instance = discord.TextChannel(data=text_channel_data, guild=guild_instance, state=mock_state) + + +class MockTextChannel(CustomMockMixin, unittest.mock.Mock, discord.mixins.Hashable): + """A class for creating mocked `discord.TextChannel` objects.""" + spec_set = text_channel_instance + + def __init__( + self, + overwrites: t.Optional[t.Dict[t.Union[discord.Role, discord.Member], discord.PermissionOverwrite]] = None, + **kwargs + ): + new_kwargs = { + "id": next(self.discord_id), + "name": "MockedTextChannel", + "guild": MockGuild() + } + new_kwargs.update(kwargs) + if "mention" not in new_kwargs: + new_kwargs["mention"] = f"&{new_kwargs['name']}" + + # Handle overwrites separately + new_kwargs["overwrites"] = {MockRole(name="@everyone", position=1, id=0): discord.PermissionOverwrite()} + if overwrites: + new_kwargs["overwrites"].update(overwrites) + + super().__init__(**new_kwargs) + + async def edit(self, **kwargs) -> None: + """ + Mimic the `discord.TextChannel.edit` method which makes a discord API call and + changes certain attributes of a text channel to only change the attributes, + without making the actual API calls. + + Note: This will fail if we attempt to edit a non-existent attributes, as this mock + only supports attribute definitions for attributes that are also present in the + spec_set model. + """ + for arg, value in kwargs.items(): + setattr(self, arg, value) + + +voice_channel_data = { + "id": 734712952342577192, + "type": "VoiceChannel", + "name": "General", + "parent_id": 734712952115953743, + "position": 1 +} +voice_channel_instance = discord.VoiceChannel(data=voice_channel_data, guild=guild_instance, state=mock_state) + + +class MockVoiceChannel(CustomMockMixin, unittest.mock.Mock, discord.mixins.Hashable): + """A class for creating mocked `discord.VoiceChannel` objects.""" + spec_set = voice_channel_instance + + def __init__(self, **kwargs): + new_kwargs = { + "id": next(self.discord_id), + "name": "MockedTextChannel", + "guild": MockGuild() + } + new_kwargs.update(kwargs) + if "mention" not in new_kwargs: + new_kwargs["mention"] = f"&{new_kwargs['name']}" + + super().__init__(**new_kwargs) + + +category_channel_data = { + "id": 734712951872815137, + "name": "Social", + "position": 1 +} +category_channel_instance = discord.CategoryChannel(data=category_channel_data, guild=guild_instance, state=mock_state) + + +class MockCategoryChannel(CustomMockMixin, unittest.mock.Mock, discord.mixins.Hashable): + """A class for creating mocked `discord.CategoryChannel` objects.""" + spec_set = category_channel_instance + + def __init__(self, **kwargs): + new_kwargs = { + "id": next(self.discord_id), + "name": "MockCategoryChannel", + "guild": MockGuild() + } + new_kwargs.update(kwargs) + super().__init__(**new_kwargs) + + +dm_channel_data = {"id": 1, "recipients": [user_instance]} +dm_channel_instance = discord.DMChannel(me=user_instance, data=dm_channel_data, state=mock_state) + + +class MockDMChannel(CustomMockMixin, unittest.mock.Mock, discord.mixins.Hashable): + """A class for creating mocked `discord.DMChannel` objects.""" + spec_set = dm_channel_instance + + def __init__(self, **kwargs): + new_kwargs = {"id": next(self.discord_id), "recipient": MockUser(), "me": MockUser(bot=True)} + new_kwargs.update(kwargs) + super().__init__(**new_kwargs) + + +message_data = { + "id": 1, + "attachments": [], + "embeds": [], + 'edited_timestamp': datetime.datetime.utcnow().replace(tzinfo=datetime.timezone.utc).isoformat(timespec="seconds"), + "type": "message", + "pinned": False, + "mention_everyone": False, + "tts": False, + "content": "I'm a message", +} +message_instance = discord.Message(data=message_data, channel=text_channel_instance, state=mock_state) + + +class MockMessage(CustomMockMixin, unittest.mock.Mock): + """A class for creating mocked `discord.Message` objects.""" + spec_set = message_instance + + def __init__(self, **kwargs): + new_kwargs = { + "id": next(self.discord_id), + "attachments": [], + "reactions": [], + "embeds": [], + "channel": MockTextChannel(), + "pinned": False, + "mention_everyone": False, + "tts": False, + "content": "MockMessage", + "stickers": [], + "author": MockMember(), + "guild": MockGuild() + } + new_kwargs.update(kwargs) + super().__init__(**new_kwargs) + + +attachment_data = { + "id": 1, + "size": 1000, + "height": 100, + "width": 300, + "filename": "my_attachment.txt", + "url": "https://www.example.com", + "proxy_url": "https://www.example.com" +} +attachment_instance = discord.Attachment(data=attachment_data, state=mock_state) + + +class MockAttachment(CustomMockMixin, unittest.mock.Mock): + """A class for creating mocked `discord.Attachment` objects.""" + spec_set = attachment_instance + + def __init__(self, **kwargs): + new_kwargs = {"id": next(self.discord_id)} + new_kwargs.update(kwargs) + super().__init__(**new_kwargs) + + +emoji_data = { + "id": 1, + "require_colons": True, + "managed": True, + "name": "coding_nerds" +} +emoji_instance = discord.Emoji(data=emoji_data, guild=guild_instance, state=mock_state) + + +class MockEmoji(CustomMockMixin, unittest.mock.Mock): + """A class for creating mocked `discord.Emoji` objects.""" + spec_set = emoji_instance + + def __init__(self, **kwargs): + new_kwargs = { + "id": next(self.discord_id), + "name": "MockEmoji", + "guild": MockGuild(), + "managed": True, + "require_colons": True + } + new_kwargs.update(kwargs) + super().__init__(**new_kwargs) + + +reaction_data = {"me": True} +reaction_instance = discord.Reaction(data=reaction_data, message=MockMessage(), emoji=MockEmoji()) + + +class MockReaction(CustomMockMixin, unittest.mock.Mock): + """A class for creating mocked `discord.Reaction` objects.""" + spec_set = reaction_instance + + def __init__(self, users: t.Optional[t.Iterable[MockUser]] = None, **kwargs): + new_kwargs = { + "emoji": MockEmoji(), + "message": MockMessage(), + } + new_kwargs.update(kwargs) + super().__init__(**new_kwargs) + + # Handle `users` separately, as they need a special __aiter__ AsyncMock + if users is None: + users = [] + + user_iterator = unittest.mock.AsyncMock() + user_iterator.__aiter__.return_value = users + self.users.return_value = user_iterator + + def __str__(self): + """Replicate the behavior of `discord.Reaction` and return str of `self.emoji`""" + return str(self.emoji) + + +webhook_data = { + "id": 1, + "type": discord.WebhookType.incoming.value +} +webhook_instance = discord.Webhook( + data=webhook_data, + adapter=unittest.mock.create_autospec(spec=discord.AsyncWebhookAdapter, spec_set=True) +) + + +class MockWebhook(CustomMockMixin, unittest.mock.Mock): + """A class for creating mocked `discord.Webhook` objects.""" + spec_set = webhook_instance + spec_asyncs_extend = ("send", "edit", "delete", "execute") + + def __init__(self, **kwargs): + new_kwargs = { + "id": next(self.discord_id), + "type": discord.WebhookType.incoming + } + # Resolve type int into discord.WebhookType enum + if "type" in kwargs: + kwargs["type"] = discord.enums.try_enum(discord.enums.WebhookType, int(kwargs["type"])) + new_kwargs.update(kwargs) + super().__init__(**new_kwargs) + + +context_instance = discord.ext.commands.Context( + prefix=bot_instance.get_prefix(message_instance), + message=message_instance +) + + +class MockContext(CustomMockMixin, unittest.mock.Mock): + """A class for creating mocked `discord.ext.commands.Context` objects.""" + spec_set = context_instance + + def __init__(self, **kwargs): + new_kwargs = { + "bot": MockBot(), + "guild": MockGuild(), + "author": MockMember(), + "channel": MockTextChannel(), + "message": MockMessage() + } + new_kwargs.update(kwargs) + super().__init__(**new_kwargs) + + +class MockBot(CustomMockMixin, unittest.mock.MagicMock): + """A class for creating mocked `bot.core.bot.Bot` objects.""" + spec_set = bot_instance + + def __init__(self, **kwargs): + super().__init__(**kwargs) + + # Replicate certain attributes that the bot needs + self.http_session = unittest.mock.create_autospec(spec=ClientSession, spec_set=True) + self.db_engine = unittest.mock.create_autospec(spec=AsyncEngine, spec_set=True)