diff --git a/modmail/bot.py b/modmail/bot.py index e62f1bda..93447437 100644 --- a/modmail/bot.py +++ b/modmail/bot.py @@ -49,14 +49,16 @@ def __init__(self, **kwargs): # allow only user mentions by default. # ! NOTE: This may change in the future to allow roles as well allowed_mentions = AllowedMentions(everyone=False, users=True, roles=False, replied_user=True) + # override passed kwargs if they are None + kwargs["case_insensitive"] = kwargs.get("case_insensitive", True) + # do not let the description be overridden. + kwargs["description"] = "Modmail bot by discord-modmail." + kwargs["status"] = kwargs.get("status", status) + kwargs["activity"] = kwargs.get("activity", activity) + kwargs["allowed_mentions"] = kwargs.get("allowed_mentions", allowed_mentions) + kwargs["command_prefix"] = kwargs.get("command_prefix", prefix) + kwargs["intents"] = kwargs.get("intents", REQUIRED_INTENTS) super().__init__( - case_insensitive=True, - description="Modmail bot by discord-modmail.", - status=status, - activity=activity, - allowed_mentions=allowed_mentions, - command_prefix=prefix, - intents=REQUIRED_INTENTS, **kwargs, ) diff --git a/modmail/extensions/utils/error_handler.py b/modmail/extensions/utils/error_handler.py index 02b059f2..cedd8f1b 100644 --- a/modmail/extensions/utils/error_handler.py +++ b/modmail/extensions/utils/error_handler.py @@ -18,7 +18,7 @@ ERROR_COLOUR = discord.Colour.red() -ERROR_TITLE_REGEX = re.compile(r"(?<=[a-zA-Z])([A-Z])(?=[a-z])") +ERROR_TITLE_REGEX = re.compile(r"((?<=[a-z])[A-Z]|(?<=[a-zA-Z])[A-Z](?=[a-z]))") ANY_DEV_MODE = BOT_MODE & (BotModes.DEVELOP.value + BotModes.PLUGIN_DEV.value) diff --git a/modmail/utils/time.py b/modmail/utils/time.py new file mode 100644 index 00000000..429609d4 --- /dev/null +++ b/modmail/utils/time.py @@ -0,0 +1,41 @@ +import datetime +import enum +import typing + +import arrow + + +class TimeStampEnum(enum.Enum): + """ + Timestamp modes for discord. + + Full docs on this format are viewable here: + https://discord.com/developers/docs/reference#message-formatting + """ + + # fmt: off + SHORT_TIME = "t" # 16:20 + LONG_TIME = "T" # 16:20:30 + SHORT_DATE = "d" # 20/04/2021 + LONG_DATE = "D" # 20 April 2021 + SHORT_DATE_TIME = "f" # 20 April 2021 16:20 + LONG_DATE_TIME = "F" # Tuesday, 20 April 2021 16:20 + RELATIVE_TIME = "R" # 2 months ago + + # fmt: on + # DEFAULT alised to the default, so for all purposes, it behaves like SHORT_DATE_TIME, including the name + DEFAULT = SHORT_DATE_TIME + + +TypeTimes = typing.Union[arrow.Arrow, datetime.datetime] + + +def get_discord_formatted_timestamp( + timestamp: TypeTimes, format: TimeStampEnum = TimeStampEnum.DEFAULT +) -> str: + """ + Return a discord formatted timestamp from a datetime compatiable datatype. + + `format` must be an enum member of TimeStampEnum. Default style is SHORT_DATE_TIME + """ + return f"" diff --git a/poetry.lock b/poetry.lock index a23ff3eb..ba14235f 100644 --- a/poetry.lock +++ b/poetry.lock @@ -238,17 +238,17 @@ cron = ["capturer (>=2.4)"] [[package]] name = "coverage" -version = "5.5" +version = "6.0.2" 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" +python-versions = ">=3.6" [package.dependencies] -toml = {version = "*", optional = true, markers = "extra == \"toml\""} +tomli = {version = "*", optional = true, markers = "extra == \"toml\""} [package.extras] -toml = ["toml"] +toml = ["tomli"] [[package]] name = "discord.py" @@ -907,16 +907,15 @@ testing = ["coverage", "hypothesis (>=5.7.1)"] [[package]] name = "pytest-cov" -version = "2.12.1" +version = "3.0.0" description = "Pytest plugin for measuring coverage." category = "dev" optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" +python-versions = ">=3.6" [package.dependencies] -coverage = ">=5.2.1" +coverage = {version = ">=5.2.1", extras = ["toml"]} pytest = ">=4.6" -toml = "*" [package.extras] testing = ["fields", "hunter", "process-tests", "six", "pytest-xdist", "virtualenv"] @@ -1441,58 +1440,39 @@ coloredlogs = [ {file = "coloredlogs-15.0.1.tar.gz", hash = "sha256:7c991aa71a4577af2f82600d8f8f3a89f936baeaf9b50a9c197da014e5bf16b0"}, ] 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"}, + {file = "coverage-6.0.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:1549e1d08ce38259de2bc3e9a0d5f3642ff4a8f500ffc1b2df73fd621a6cdfc0"}, + {file = "coverage-6.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bcae10fccb27ca2a5f456bf64d84110a5a74144be3136a5e598f9d9fb48c0caa"}, + {file = "coverage-6.0.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:53a294dc53cfb39c74758edaa6305193fb4258a30b1f6af24b360a6c8bd0ffa7"}, + {file = "coverage-6.0.2-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:8251b37be1f2cd9c0e5ccd9ae0380909c24d2a5ed2162a41fcdbafaf59a85ebd"}, + {file = "coverage-6.0.2-cp310-cp310-win32.whl", hash = "sha256:db42baa892cba723326284490283a68d4de516bfb5aaba369b4e3b2787a778b7"}, + {file = "coverage-6.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:bbffde2a68398682623d9dd8c0ca3f46fda074709b26fcf08ae7a4c431a6ab2d"}, + {file = "coverage-6.0.2-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:60e51a3dd55540bec686d7fff61b05048ca31e804c1f32cbb44533e6372d9cc3"}, + {file = "coverage-6.0.2-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6a6a9409223a27d5ef3cca57dd7cd4dfcb64aadf2fad5c3b787830ac9223e01a"}, + {file = "coverage-6.0.2-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:4b34ae4f51bbfa5f96b758b55a163d502be3dcb24f505d0227858c2b3f94f5b9"}, + {file = "coverage-6.0.2-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:3bbda1b550e70fa6ac40533d3f23acd4f4e9cb4e6e77251ce77fdf41b3309fb2"}, + {file = "coverage-6.0.2-cp36-cp36m-win32.whl", hash = "sha256:4e28d2a195c533b58fc94a12826f4431726d8eb029ac21d874345f943530c122"}, + {file = "coverage-6.0.2-cp36-cp36m-win_amd64.whl", hash = "sha256:a82d79586a0a4f5fd1cf153e647464ced402938fbccb3ffc358c7babd4da1dd9"}, + {file = "coverage-6.0.2-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:3be1206dc09fb6298de3fce70593e27436862331a85daee36270b6d0e1c251c4"}, + {file = "coverage-6.0.2-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c9cd3828bbe1a40070c11fe16a51df733fd2f0cb0d745fb83b7b5c1f05967df7"}, + {file = "coverage-6.0.2-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:d036dc1ed8e1388e995833c62325df3f996675779541f682677efc6af71e96cc"}, + {file = "coverage-6.0.2-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:04560539c19ec26995ecfb3d9307ff154fbb9a172cb57e3b3cfc4ced673103d1"}, + {file = "coverage-6.0.2-cp37-cp37m-win32.whl", hash = "sha256:e4fb7ced4d9dec77d6cf533acfbf8e1415fe799430366affb18d69ee8a3c6330"}, + {file = "coverage-6.0.2-cp37-cp37m-win_amd64.whl", hash = "sha256:77b1da5767ed2f44611bc9bc019bc93c03fa495728ec389759b6e9e5039ac6b1"}, + {file = "coverage-6.0.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:61b598cbdbaae22d9e34e3f675997194342f866bb1d781da5d0be54783dce1ff"}, + {file = "coverage-6.0.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:36e9040a43d2017f2787b28d365a4bb33fcd792c7ff46a047a04094dc0e2a30d"}, + {file = "coverage-6.0.2-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:9f1627e162e3864a596486774876415a7410021f4b67fd2d9efdf93ade681afc"}, + {file = "coverage-6.0.2-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:e7a0b42db2a47ecb488cde14e0f6c7679a2c5a9f44814393b162ff6397fcdfbb"}, + {file = "coverage-6.0.2-cp38-cp38-win32.whl", hash = "sha256:a1b73c7c4d2a42b9d37dd43199c5711d91424ff3c6c22681bc132db4a4afec6f"}, + {file = "coverage-6.0.2-cp38-cp38-win_amd64.whl", hash = "sha256:1db67c497688fd4ba85b373b37cc52c50d437fd7267520ecd77bddbd89ea22c9"}, + {file = "coverage-6.0.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:f2f184bf38e74f152eed7f87e345b51f3ab0b703842f447c22efe35e59942c24"}, + {file = "coverage-6.0.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cd1cf1deb3d5544bd942356364a2fdc8959bad2b6cf6eb17f47d301ea34ae822"}, + {file = "coverage-6.0.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:ad9b8c1206ae41d46ec7380b78ba735ebb77758a650643e841dd3894966c31d0"}, + {file = "coverage-6.0.2-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:381d773d896cc7f8ba4ff3b92dee4ed740fb88dfe33b6e42efc5e8ab6dfa1cfe"}, + {file = "coverage-6.0.2-cp39-cp39-win32.whl", hash = "sha256:424c44f65e8be58b54e2b0bd1515e434b940679624b1b72726147cfc6a9fc7ce"}, + {file = "coverage-6.0.2-cp39-cp39-win_amd64.whl", hash = "sha256:abbff240f77347d17306d3201e14431519bf64495648ca5a49571f988f88dee9"}, + {file = "coverage-6.0.2-pp36-none-any.whl", hash = "sha256:7092eab374346121805fb637572483270324407bf150c30a3b161fc0c4ca5164"}, + {file = "coverage-6.0.2-pp37-none-any.whl", hash = "sha256:30922626ce6f7a5a30bdba984ad21021529d3d05a68b4f71ea3b16bda35b8895"}, + {file = "coverage-6.0.2.tar.gz", hash = "sha256:6807947a09510dc31fa86f43595bf3a14017cd60bf633cc746d52141bfa6b149"}, ] "discord.py" = [] distlib = [ @@ -1883,8 +1863,8 @@ pytest-asyncio = [ {file = "pytest_asyncio-0.15.1-py3-none-any.whl", hash = "sha256:3042bcdf1c5d978f6b74d96a151c4cfb9dcece65006198389ccd7e6c60eb1eea"}, ] 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"}, + {file = "pytest-cov-3.0.0.tar.gz", hash = "sha256:e7f0f5b1617d2210a2cabc266dfe2f4c75a8d32fb89eafb7ad9d06f6d076d470"}, + {file = "pytest_cov-3.0.0-py3-none-any.whl", hash = "sha256:578d5d15ac4a25e5f961c938b85a05b09fdaae9deef3bb6de9a6e766622ca7a6"}, ] pytest-dependency = [ {file = "pytest-dependency-0.5.1.tar.gz", hash = "sha256:c2a892906192663f85030a6ab91304e508e546cddfe557d692d61ec57a1d946b"}, diff --git a/pyproject.toml b/pyproject.toml index 16bc94f5..0cd1fdb9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -45,10 +45,10 @@ isort = "^5.9.2" pep8-naming = "~=0.11" # testing codecov = "^2.1.11" -coverage = { extras = ["toml"], version = "^5.5" } +coverage = { extras = ["toml"], version = "^6.0.2" } pytest = "^6.2.4" pytest-asyncio = "^0.15.1" -pytest-cov = "^2.12.1" +pytest-cov = "^3.0.0" pytest-dependency = "^0.5.1" pytest-sugar = "^0.9.4" pytest-xdist = { version = "^2.3.0", extras = ["psutil"] } @@ -67,13 +67,13 @@ build-backend = "poetry.core.masonry.api" [tool.coverage.run] branch = true -source_pkgs = ["modmail"] +source_pkgs = ['modmail', 'tests.modmail'] omit = ["modmail/plugins/**.*"] [tool.pytest.ini_options] addopts = "--cov --cov-report=" minversion = "6.0" -testpaths = ["tests"] +testpaths = ["tests/modmail"] [tool.black] line-length = 110 @@ -93,3 +93,4 @@ lint = { cmd = "pre-commit run --all-files", help = "Checks all files for CI err precommit = { cmd = "pre-commit install --install-hooks", help = "Installs the precommit hook" } report = { cmd = "coverage report", help = "Show coverage report from previously run tests." } test = { cmd = "pytest -n auto --dist loadfile", help = "Runs tests and save results to a coverage report" } +test_mocks = { cmd = 'pytest tests/test_mocks.py', help = 'Runs the tests on the mock files. They are excluded from the main test suite.' } diff --git a/tests/conftest.py b/tests/conftest.py index 5cbb797c..759b9108 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,6 +1,6 @@ import pytest -def pytest_report_header(config): +def pytest_report_header(config) -> str: """Pytest headers.""" return "package: modmail" diff --git a/tests/mocks.py b/tests/mocks.py new file mode 100644 index 00000000..fed6b01d --- /dev/null +++ b/tests/mocks.py @@ -0,0 +1,832 @@ +""" +Helper methods for testing. + +Slight modifications have been made to support our bot. + +Additional modifications have been made to mocked class method side_effects +in order to return the proper mock type, if it exists. + +Original Source: +https://github.com/python-discord/bot/blob/d183d03fa2939bebaac3da49646449fdd4d00e6c/tests/helpers.py# noqa: E501 + +MIT License + +Copyright (c) 2018 Python Discord + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +""" +from __future__ import annotations + +import asyncio +import collections +import datetime +import itertools +import typing +import unittest.mock +from typing import TYPE_CHECKING, Iterable, Optional + +import aiohttp +import arrow +import discord +import discord.ext.commands +import discord.mixins + +import modmail.bot + + +_snowflake_count = itertools.count(1) + +__all__ = [ + "generate_realistic_id", + "HashableMixin", + "CustomMockMixin", + "ColourMixin", + "MockAttachment", + "MockBot", + "MockCategoryChannel", + "MockContext", + "MockClientUser", + "MockDMChannel", + "MockEmoji", + "MockGuild", + "MockMember", + "MockMessage", + "MockPartialEmoji", + "MockReaction", + "MockRole", + "MockTextChannel", + "MockThread", + "MockUser", + "MockVoiceChannel", + "MockWebhook", +] + + +def generate_realistic_id(time: typing.Union[arrow.Arrow, datetime.datetime] = None, /) -> int: + """ + Generate realistic id, based from the current time. + + If a time is provided, this will use that time for the time generation. + """ + if time is None: + time = arrow.utcnow() + return discord.utils.time_snowflake(time) + next(_snowflake_count) + + +class GenerateID: + """Class to be able to use next() to generate new ids.""" + + def __next__(self) -> int: + return generate_realistic_id() + + +class HashableMixin(discord.mixins.EqualityComparable): + """ + Mixin that provides similar hashing and equality functionality as discord.py's `Hashable` mixin. + + Given that most of our features need the created_at function to work, we are typically using + full fake discord ids, and so we still bitshift the id like Dpy does. + """ + + if TYPE_CHECKING: # pragma: nocover + id: int + + def __hash__(self): + return self.id >> 22 + + +class ColourMixin: + """A mixin for Mocks that provides the aliasing of (accent_)color->(accent_)colour like discord.py.""" + + @property + def color(self) -> discord.Colour: + """Alias of colour.""" + return self.colour + + @color.setter + def color(self, color: discord.Colour) -> None: + self.colour = color + + @property + def accent_color(self) -> discord.Colour: + """Alias of accent_colour.""" + return self.accent_colour + + @accent_color.setter + def accent_color(self, color: discord.Colour) -> None: + self.accent_colour = color + + +def generate_mock_message(content=unittest.mock.DEFAULT, *args, **kwargs): + return MockMessage(content=content) + + +# all of the classes here can be created from a mock object +# the key is the object, and the method is a factory method for creating a new instance +# some of the factories can factory take their input and pass it to the mock object. +COPYABLE_MOCKS = { + discord.Attachment: lambda *args, **kwargs: MockAttachment(), + discord.ext.commands.Bot: lambda *args, **kwargs: MockBot(), + discord.CategoryChannel: lambda *args, **kwargs: MockCategoryChannel(), + discord.ext.commands.Context: lambda *args, **kwargs: MockContext(), + discord.ClientUser: lambda *args, **kwargs: MockClientUser(), + discord.DMChannel: lambda *args, **kwargs: MockDMChannel(), + discord.Emoji: lambda *args, **kwargs: MockEmoji(), + discord.Guild: lambda *args, **kwargs: MockGuild(), + discord.Member: lambda *args, **kwargs: MockMember(), + discord.Message: generate_mock_message, + discord.PartialEmoji: lambda *args, **kwargs: MockPartialEmoji(), + discord.Reaction: lambda *args, **kwargs: MockReaction(), + discord.Role: lambda *args, **kwargs: MockRole(), + discord.TextChannel: lambda *args, **kwargs: MockTextChannel(), + discord.Thread: lambda *args, **kwargs: MockThread(), + discord.User: lambda *args, **kwargs: MockUser(), + discord.VoiceChannel: lambda *args, **kwargs: MockVoiceChannel(), + discord.Webhook: lambda *args, **kwargs: MockWebhook(), +} + + +class CustomMockMixin: + """ + Provides common functionality for our custom Mock types. + + The `_get_child_mock` method automatically returns an AsyncMock for coroutine methods of the mock + object. As discord.py also uses synchronous methods that nonetheless return coroutine objects, the + class attribute `additional_spec_asyncs` can be overwritten with an iterable containing additional + attribute names that should also mocked with an AsyncMock instead of a regular MagicMock/Mock. The + class method `spec_set` can be overwritten with the object that should be uses as the specification + for the mock. + + Mock/MagicMock subclasses that use this mixin only need to define `__init__` method if they need to + implement custom behavior. + """ + + child_mock_type = unittest.mock.MagicMock + discord_id = GenerateID() + spec_set = None + additional_spec_asyncs = None + + def __init__(self, **kwargs): + name = kwargs.pop( + "name", None + ) # `name` has special meaning for Mock classes, so we need to set it manually. + super().__init__(spec_set=self.spec_set, **kwargs) + + if self.additional_spec_asyncs: + self._spec_asyncs.extend(self.additional_spec_asyncs) + + if name: + self.name = name + + # make all of the methods return the proper mock type + # configure mock + mock_config = {} + for attr in dir(self.spec_set): + if attr.startswith("__") and attr.endswith("__"): + # ignore all magic attributes + continue + try: + attr = getattr(self.spec_set, attr) + except AttributeError: + continue + # we only want functions, so we can properly mock their return + if not callable(attr): + continue + + if isinstance(attr, (unittest.mock.Mock, unittest.mock.AsyncMock)): + # skip all mocks + continue + + try: + hints = typing.get_type_hints(attr) + except NameError: + hints = attr.__annotations__ + + # fix not typed methods + # this list can be added to as methods are discovered + if attr.__name__ == "send": + hints["return"] = discord.Message + elif attr.__name__ == "edit": + hints["return"] = type(self.spec_set) + + if hints.get("return") is None: + continue + + klass = hints["return"] + + if isinstance(klass, str): + klass_name = klass + elif hasattr(klass, "__name__"): + klass_name = klass.__name__ + else: + continue + + method = None + for cls in COPYABLE_MOCKS: + if klass_name == cls.__name__: + method = COPYABLE_MOCKS[cls] + break + + if not method: + continue + + # print(self.__class__, attr.__name__, cls) + + mock_config[f"{attr.__name__}.side_effect"] = method + + self.configure_mock(**mock_config) + + def _get_child_mock(self, **kw): + """ + Overwrite of the `_get_child_mock` method to stop the propagation of our custom mock classes. + + Mock objects automatically create children when you access an attribute or call a method on them. + By default, the class of these children is the type of the parent itself. + However, this would mean that the children created for our custom mock types would also be instances + of that custom mock type. This is not desirable, as attributes of, e.g., a `Bot` object are not + `Bot` objects themselves. The Python docs for `unittest.mock` hint that overwriting this method is the + best way to deal with that. + + This override will look for an attribute called `child_mock_type` and + use that as the type of the child mock. + """ + _new_name = kw.get("_new_name") + if _new_name in self.__dict__["_spec_asyncs"]: + return unittest.mock.AsyncMock(**kw) + + if isinstance(self, unittest.mock.MagicMock) and _new_name in unittest.mock._async_method_magics: + # Any asynchronous magic becomes an AsyncMock + klass = unittest.mock.AsyncMock + else: + klass = self.child_mock_type + + # if the mock is sealed, that means that new mocks cannot be created when accessing attributes + if self._mock_sealed: + attribute = "." + kw["name"] if "name" in kw else "()" + mock_name = self._extract_mock_name() + attribute + raise AttributeError(mock_name) + + return klass(**kw) + + +# Create a guild instance to get a realistic Mock of `discord.Guild` +guild_data = { + "id": generate_realistic_id(), + "name": "guild", + "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": generate_realistic_id(), + "description": "mocking is fun", + "max_presences": 10_000, + "max_members": 100_000, + "preferred_locale": "UTC", + "owner_id": 1, + "afk_channel_id": generate_realistic_id(), +} +guild_instance = discord.Guild(data=guild_data, state=unittest.mock.MagicMock()) + + +class MockGuild(CustomMockMixin, unittest.mock.NonCallableMock, HashableMixin): + """ + A `Mock` subclass to mock `discord.Guild` objects. + + A MockGuild instance will follow the specifications of a `discord.Guild` instance. This means + that if the code you're testing tries to access an attribute or method that normally does not + exist for a `discord.Guild` object this will raise an `AttributeError`. This is to make sure our + tests fail if the code we're testing uses a `discord.Guild` object in the wrong way. + + One restriction of that is that if the code tries to access an attribute that normally does not + exist for `discord.Guild` instance but was added dynamically, this will raise an exception with + the mocked object. To get around that, you can set the non-standard attribute explicitly for the + instance of `MockGuild`: + + >>> guild = MockGuild() + >>> guild.attribute_that_normally_does_not_exist = unittest.mock.MagicMock() + + In addition to attribute simulation, mocked guild object will pass an `isinstance` check against + `discord.Guild`: + + >>> guild = MockGuild() + >>> isinstance(guild, discord.Guild) + True + + """ + + spec_set = guild_instance + + def __init__(self, roles: Optional[Iterable[MockRole]] = None, **kwargs) -> None: + default_kwargs = {"id": next(self.discord_id), "members": []} + super().__init__(**collections.ChainMap(kwargs, default_kwargs)) + + self.roles = [MockRole(name="@everyone", position=1, id=0)] + if roles: + self.roles.extend(roles) + + +# Create a Role instance to get a realistic Mock of `discord.Role` +role_data = { + "name": "role", + "id": generate_realistic_id(), +} +role_instance = discord.Role(guild=guild_instance, state=unittest.mock.MagicMock(), data=role_data) + + +class MockRole(CustomMockMixin, unittest.mock.NonCallableMock, ColourMixin, HashableMixin): + """ + A Mock subclass to mock `discord.Role` objects. + + Instances of this class will follow the specifications of `discord.Role` instances. For more + information, see the `MockGuild` docstring. + """ + + spec_set = role_instance + + def __init__(self, **kwargs) -> None: + default_kwargs = { + "id": next(self.discord_id), + "name": "role", + "position": 1, + "colour": discord.Colour(0xDEADBF), + "permissions": discord.Permissions(), + } + super().__init__(**collections.ChainMap(kwargs, default_kwargs)) + + if isinstance(self.colour, int): + self.colour = discord.Colour(self.colour) + + if isinstance(self.permissions, int): + self.permissions = discord.Permissions(self.permissions) + + if "mention" not in kwargs: + self.mention = f"&{self.name}" + + def __lt__(self, other): + """Simplified position-based comparisons similar to those of `discord.Role`.""" + return self.position < other.position + + def __ge__(self, other): + """Simplified position-based comparisons similar to those of `discord.Role`.""" + return self.position >= other.position + + +# Create a Member instance to get a realistic Mock of `discord.Member` +member_data = { + "user": "lemon", + "roles": [1], +} +member_instance = discord.Member(data=member_data, guild=guild_instance, state=unittest.mock.MagicMock()) + + +class MockMember(CustomMockMixin, unittest.mock.NonCallableMock, ColourMixin, HashableMixin): + """ + A Mock subclass to mock Member objects. + + Instances of this class will follow the specifications of `discord.Member` instances. For more + information, see the `MockGuild` docstring. + """ + + spec_set = member_instance + + def __init__(self, roles: Optional[Iterable[MockRole]] = None, **kwargs) -> None: + default_kwargs = {"name": "member", "id": next(self.discord_id), "bot": False, "pending": False} + super().__init__(**collections.ChainMap(kwargs, default_kwargs)) + + self.roles = [MockRole(name="@everyone", position=1, id=0)] + if roles: + self.roles.extend(roles) + self.top_role = max(self.roles) + + if "mention" not in kwargs: + self.mention = f"@{self.name}" + + +# Create a User instance to get a realistic Mock of `discord.User` +_user_data_mock = collections.defaultdict(unittest.mock.MagicMock, {"accent_color": 0}) +user_instance = discord.User( + data=unittest.mock.MagicMock(get=unittest.mock.Mock(side_effect=_user_data_mock.get)), + state=unittest.mock.MagicMock(), +) + + +class MockUser(CustomMockMixin, unittest.mock.NonCallableMock, ColourMixin, HashableMixin): + """ + A Mock subclass to mock User objects. + + Instances of this class will follow the specifications of `discord.User` instances. For more + information, see the `MockGuild` docstring. + """ + + spec_set = user_instance + + def __init__(self, **kwargs) -> None: + kwargs["name"] = kwargs.get("name", "user") + kwargs["id"] = kwargs.get("id", next(self.discord_id)) + kwargs["bot"] = kwargs.get("bot", False) + super().__init__(**kwargs) + + if "mention" not in kwargs: + self.mention = f"@{self.name}" + + +# Create a User instance to get a realistic Mock of `discord.ClientUser` +_user_data_mock = collections.defaultdict(unittest.mock.MagicMock, {"accent_color": 0}) +clientuser_instance = discord.ClientUser( + data=unittest.mock.MagicMock(get=unittest.mock.Mock(side_effect=_user_data_mock.get)), + state=unittest.mock.MagicMock(), +) + + +class MockClientUser(CustomMockMixin, unittest.mock.NonCallableMock, ColourMixin, HashableMixin): + """ + A Mock subclass to mock ClientUser objects. + + Instances of this class will follow the specifications of `discord.ClientUser` instances. For more + information, see the `MockGuild` docstring. + """ + + spec_set = clientuser_instance + + def __init__(self, **kwargs) -> None: + default_kwargs = {"name": "user", "id": next(self.discord_id), "bot": True} + super().__init__(**collections.ChainMap(kwargs, default_kwargs)) + + if "mention" not in kwargs: + self.mention = f"@{self.name}" + + +def _get_mock_loop() -> unittest.mock.Mock: + """Return a mocked asyncio.AbstractEventLoop.""" + loop = unittest.mock.create_autospec(spec=asyncio.AbstractEventLoop, spec_set=True) + + # Since calling `create_task` on our MockBot does not actually schedule the coroutine object + # as a task in the asyncio loop, this `side_effect` calls `close()` on the coroutine object + # to prevent "has not been awaited"-warnings. + def mock_create_task(coroutine, **kwargs): + coroutine.close() + return unittest.mock.Mock() + + loop.create_task.side_effect = mock_create_task + + return loop + + +class MockBot(CustomMockMixin, unittest.mock.NonCallableMock): + """ + A MagicMock subclass to mock Bot objects. + + Instances of this class will follow the specifications of `discord.ext.commands.Bot` instances. + For more information, see the `MockGuild` docstring. + """ + + spec_set = modmail.bot.ModmailBot( + command_prefix=unittest.mock.MagicMock(), + loop=_get_mock_loop(), + ) + additional_spec_asyncs = ("wait_for", "redis_ready") + + def __init__(self, **kwargs) -> None: + super().__init__(**kwargs) + self.user = MockClientUser() + + self.loop = _get_mock_loop() + self.http_session = unittest.mock.create_autospec(spec=aiohttp.ClientSession, spec_set=True) + + +# Create a TextChannel instance to get a realistic MagicMock of `discord.TextChannel` +channel_data = { + "id": generate_realistic_id(), + "type": "TextChannel", + "name": "channel", + "parent_id": generate_realistic_id(), + "topic": "topic", + "position": 1, + "nsfw": False, + "last_message_id": generate_realistic_id(), +} +text_channel_instance = discord.TextChannel( + state=unittest.mock.MagicMock(), guild=unittest.mock.MagicMock(), data=channel_data +) + +channel_data["type"] = "VoiceChannel" +voice_channel_instance = discord.VoiceChannel( + state=unittest.mock.MagicMock(), guild=unittest.mock.MagicMock(), data=channel_data +) + + +class MockTextChannel(CustomMockMixin, unittest.mock.NonCallableMock, HashableMixin): + """ + A MagicMock subclass to mock TextChannel objects. + + Instances of this class will follow the specifications of `discord.TextChannel` instances. For + more information, see the `MockGuild` docstring. + """ + + spec_set = text_channel_instance + + def __init__(self, **kwargs) -> None: + default_kwargs = {"id": next(self.discord_id), "name": "channel", "guild": MockGuild()} + super().__init__(**collections.ChainMap(kwargs, default_kwargs)) + + if "mention" not in kwargs: + self.mention = f"#{self.name}" + + +class MockVoiceChannel(CustomMockMixin, unittest.mock.NonCallableMock, HashableMixin): + """ + A MagicMock subclass to mock VoiceChannel objects. + + Instances of this class will follow the specifications of `discord.VoiceChannel` instances. For + more information, see the `MockGuild` docstring. + """ + + spec_set = voice_channel_instance + + def __init__(self, **kwargs) -> None: + default_kwargs = {"id": next(self.discord_id), "name": "voice_channel", "guild": MockGuild()} + super().__init__(**collections.ChainMap(kwargs, default_kwargs)) + + if "mention" not in kwargs: + self.mention = f"#{self.name}" + + +# Create data for the DMChannel instance +dm_channel_data = { + "id": generate_realistic_id(), + "recipients": [unittest.mock.MagicMock()], +} +dm_channel_instance = discord.DMChannel( + me=unittest.mock.MagicMock(), state=unittest.mock.MagicMock(), data=dm_channel_data +) + + +class MockDMChannel(CustomMockMixin, unittest.mock.NonCallableMock, HashableMixin): + """ + A MagicMock subclass to mock DMChannel objects. + + Instances of this class will follow the specifications of `discord.DMChannel` instances. For + more information, see the `MockGuild` docstring. + """ + + spec_set = dm_channel_instance + + def __init__(self, **kwargs) -> None: + default_kwargs = {"id": next(self.discord_id), "recipient": MockUser(), "me": MockUser()} + super().__init__(**collections.ChainMap(kwargs, default_kwargs)) + + +# Create CategoryChannel instance to get a realistic MagicMock of `discord.CategoryChannel` +category_channel_data = { + "id": generate_realistic_id(), + "type": discord.ChannelType.category, + "name": "category", + "position": 1, +} + +category_channel_instance = discord.CategoryChannel( + state=unittest.mock.MagicMock(), guild=unittest.mock.MagicMock(), data=category_channel_data +) + + +class MockCategoryChannel(CustomMockMixin, unittest.mock.NonCallableMock, HashableMixin): + """ + A MagicMock subclass to mock CategoryChannel objects. + + Instances of this class will follow the specifications of `discord.CategoryChannel` instances. For + more information, see the `MockGuild` docstring. + """ + + spec_set = category_channel_instance + + def __init__(self, **kwargs) -> None: + default_kwargs = {"id": next(self.discord_id)} + super().__init__(**collections.ChainMap(default_kwargs, kwargs)) + + +# Create a thread instance to get a realistic MagicMock of `discord.Thread` +thread_metadata = { + "archived": False, + "archiver_id": None, + "auto_archive_duration": 1440, + "archive_timestamp": "2021-10-17T20:35:48.058121+00:00", +} +thread_data = { + "id": generate_realistic_id(), + "parent_id": generate_realistic_id(), + "owner_id": generate_realistic_id(), + "name": "user-0005", + "type": discord.ChannelType.public_thread, + "last_message_id": None, + "message_count": 1, + "member_count": 2, + "thread_metadata": thread_metadata, +} + +thread_instance = discord.Thread( + state=unittest.mock.MagicMock(), guild=unittest.mock.MagicMock(), data=thread_data +) + + +class MockThread(CustomMockMixin, unittest.mock.NonCallableMock, HashableMixin): + """ + A MagicMock subclass to mock Thread objects. + + Instances of this class will follow the specifications of `discord.Thread` instances. For + more information, see the `MockGuild` docstring. + """ + + spec_set = thread_instance + + def __init__(self, **kwargs) -> None: + default_kwargs = {"id": next(self.discord_id)} + super().__init__(**collections.ChainMap(default_kwargs, kwargs)) + + +# Create a Message instance to get a realistic MagicMock of `discord.Message` +message_data = { + "id": generate_realistic_id(), + "webhook_id": generate_realistic_id(), + "attachments": [], + "embeds": [], + "application": "Discord Modmail", + "activity": "mocking", + "channel": unittest.mock.MagicMock(), + "edited_timestamp": "2019-10-14T15:33:48+00:00", + "type": "message", + "pinned": False, + "mention_everyone": False, + "tts": None, + "content": "content", + "nonce": None, +} +message_instance = discord.Message( + state=unittest.mock.MagicMock(), channel=unittest.mock.MagicMock(), data=message_data +) + + +# Create a Context instance to get a realistic MagicMock of `discord.ext.commands.Context` +context_instance = discord.ext.commands.Context( + message=unittest.mock.MagicMock(), prefix="$", bot=MockBot(), view=None +) +context_instance.invoked_from_error_handler = None + + +class MockContext(CustomMockMixin, unittest.mock.NonCallableMagicMock): + """ + A MagicMock subclass to mock Context objects. + + Instances of this class will follow the specifications of `discord.ext.commands.Context` + instances. For more information, see the `MockGuild` docstring. + """ + + spec_set = context_instance + + def __init__(self, **kwargs) -> None: + super().__init__(**kwargs) + self.bot: MockBot = kwargs.get("bot", MockBot()) + self.guild: typing.Optional[MockGuild] = kwargs.get( + "guild", MockGuild(me=MockMember(id=self.bot.user.id, bot=True)) + ) + self.author: typing.Union[MockMember, MockUser] = kwargs.get("author", MockMember()) + self.channel: typing.Union[MockTextChannel, MockThread, MockDMChannel] = kwargs.get( + "channel", MockTextChannel(guild=self.guild) + ) + self.message: MockMessage = kwargs.get( + "message", MockMessage(author=self.author, channel=self.channel) + ) + self.invoked_from_error_handler = kwargs.get("invoked_from_error_handler", False) + + @property + def me(self) -> typing.Union[MockMember, MockClientUser]: + """Similar to MockGuild.me except will return the class MockClientUser if guild is None.""" + # bot.user will never be None at this point. + return self.guild.me if self.guild is not None else self.bot.user + + +attachment_instance = discord.Attachment( + data=unittest.mock.MagicMock(id=generate_realistic_id()), state=unittest.mock.MagicMock() +) + + +class MockAttachment(CustomMockMixin, unittest.mock.NonCallableMagicMock): + """ + A MagicMock subclass to mock Attachment objects. + + Instances of this class will follow the specifications of `discord.Attachment` instances. For + more information, see the `MockGuild` docstring. + """ + + spec_set = attachment_instance + + +class MockMessage(CustomMockMixin, unittest.mock.NonCallableMagicMock): + """ + A MagicMock subclass to mock Message objects. + + Instances of this class will follow the specifications of `discord.Message` instances. For more + information, see the `MockGuild` docstring. + """ + + spec_set = message_instance + + def __init__(self, **kwargs) -> None: + default_kwargs = {"attachments": []} + kwargs["id"] = kwargs.get("id", generate_realistic_id()) + super().__init__(**collections.ChainMap(kwargs, default_kwargs)) + self.author = kwargs.get("author", MockMember()) + self.channel = kwargs.get("channel", MockTextChannel()) + + +emoji_data = {"require_colons": True, "managed": True, "id": generate_realistic_id(), "name": "hyperlemon"} +emoji_instance = discord.Emoji(guild=MockGuild(), state=unittest.mock.MagicMock(), data=emoji_data) + + +class MockEmoji(CustomMockMixin, unittest.mock.NonCallableMagicMock): + """ + A MagicMock subclass to mock Emoji objects. + + Instances of this class will follow the specifications of `discord.Emoji` instances. For more + information, see the `MockGuild` docstring. + """ + + spec_set = emoji_instance + + def __init__(self, **kwargs) -> None: + super().__init__(**kwargs) + self.guild = kwargs.get("guild", MockGuild()) + + +partial_emoji_instance = discord.PartialEmoji(animated=False, name="guido") + + +class MockPartialEmoji(CustomMockMixin, unittest.mock.NonCallableMagicMock): + """ + A MagicMock subclass to mock PartialEmoji objects. + + Instances of this class will follow the specifications of `discord.PartialEmoji` instances. For + more information, see the `MockGuild` docstring. + """ + + spec_set = partial_emoji_instance + + +reaction_instance = discord.Reaction(message=MockMessage(), data={"me": True}, emoji=MockEmoji()) + + +class MockReaction(CustomMockMixin, unittest.mock.NonCallableMagicMock): + """ + A MagicMock subclass to mock Reaction objects. + + Instances of this class will follow the specifications of `discord.Reaction` instances. For + more information, see the `MockGuild` docstring. + """ + + spec_set = reaction_instance + + def __init__(self, **kwargs) -> None: + _users = kwargs.pop("users", []) + super().__init__(**kwargs) + self.emoji = kwargs.get("emoji", MockEmoji()) + self.message = kwargs.get("message", MockMessage()) + + user_iterator = unittest.mock.AsyncMock() + user_iterator.__aiter__.return_value = _users + self.users.return_value = user_iterator + + self.__str__.return_value = str(self.emoji) + + +webhook_instance = discord.Webhook(data=unittest.mock.MagicMock(), session=unittest.mock.MagicMock()) + + +class MockWebhook(CustomMockMixin, unittest.mock.NonCallableMagicMock): + """ + A MagicMock subclass to mock Webhook objects using an AsyncWebhookAdapter. + + Instances of this class will follow the specifications of `discord.Webhook` instances. For + more information, see the `MockGuild` docstring. + """ + + spec_set = webhook_instance + additional_spec_asyncs = ("send", "edit", "delete", "execute") diff --git a/tests/modmail/extensions/__init__.py b/tests/modmail/extensions/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/modmail/extensions/test_extension_manager.py b/tests/modmail/extensions/test_extension_manager.py new file mode 100644 index 00000000..717a6b01 --- /dev/null +++ b/tests/modmail/extensions/test_extension_manager.py @@ -0,0 +1,32 @@ +from copy import copy + +import pytest + +from modmail.extensions.extension_manager import ExtensionConverter +from modmail.utils.extensions import EXTENSIONS as GLOBAL_EXTENSIONS +from modmail.utils.extensions import walk_extensions + + +# load EXTENSIONS +EXTENSIONS = copy(GLOBAL_EXTENSIONS) +EXTENSIONS.update(walk_extensions()) + + +class TestExtensionConverter: + """Test the extension converter converts extensions properly.""" + + all_extensions = {x: y for x, y in walk_extensions()} + + @pytest.fixture(scope="class", name="converter") + def converter(self) -> ExtensionConverter: + """Fixture method for a ExtensionConverter object.""" + return ExtensionConverter() + + @pytest.mark.asyncio + @pytest.mark.parametrize("extension", [e.rsplit(".", 1)[-1] for e in all_extensions.keys()]) + async def test_conversion_success(self, extension: str, converter: ExtensionConverter) -> None: + """Test all extensions in the list are properly converted.""" + converter.source_list = self.all_extensions + converted = await converter.convert(None, extension) + + assert converted.endswith(extension) diff --git a/tests/modmail/extensions/test_plugin_manager.py b/tests/modmail/extensions/test_plugin_manager.py new file mode 100644 index 00000000..eddae16d --- /dev/null +++ b/tests/modmail/extensions/test_plugin_manager.py @@ -0,0 +1,32 @@ +from copy import copy + +import pytest + +from modmail.extensions.plugin_manager import PluginConverter +from modmail.utils.plugins import PLUGINS as GLOBAL_PLUGINS +from modmail.utils.plugins import walk_plugins + + +# load EXTENSIONS +PLUGINS = copy(GLOBAL_PLUGINS) +PLUGINS.update(walk_plugins()) + + +class TestPluginConverter: + """Test the extension converter converts extensions properly.""" + + all_plugins = {x: y for x, y in walk_plugins()} + + @pytest.fixture(scope="class", name="converter") + def converter(self) -> PluginConverter: + """Fixture method for a PluginConverter object.""" + return PluginConverter() + + @pytest.mark.asyncio + @pytest.mark.parametrize("plugin", [e.rsplit(".", 1)[-1] for e in all_plugins.keys()]) + async def test_conversion_success(self, plugin: str, converter: PluginConverter) -> None: + """Test all plugins in the list are properly converted.""" + converter.source_list = self.all_plugins + converted = await converter.convert(None, plugin) + + assert converted.endswith(plugin) diff --git a/tests/modmail/extensions/utils/__init__.py b/tests/modmail/extensions/utils/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/modmail/extensions/utils/test_error_handler.py b/tests/modmail/extensions/utils/test_error_handler.py new file mode 100644 index 00000000..8dbfc676 --- /dev/null +++ b/tests/modmail/extensions/utils/test_error_handler.py @@ -0,0 +1,174 @@ +import inspect +import typing +import unittest.mock + +import discord +import pytest +from discord.ext import commands + +from modmail.extensions.utils import error_handler +from modmail.extensions.utils.error_handler import ErrorHandler +from tests import mocks + + +@pytest.fixture +def cog(): + """Pytest fixture for error_handler.""" + return ErrorHandler(mocks.MockBot()) + + +@pytest.fixture +def ctx(): + """Pytest fixture for MockContext.""" + return mocks.MockContext(channel=mocks.MockTextChannel()) + + +def test_error_embed(): + """Test the error embed method creates the correct embed.""" + title = "Something very drastic went very wrong!" + message = "seven southern seas are ready to collapse." + embed = ErrorHandler.error_embed(title=title, message=message) + + assert embed.title == title + assert embed.description == message + assert embed.colour == error_handler.ERROR_COLOUR + + +@pytest.mark.parametrize( + ["exception_or_str", "expected_str"], + [ + [commands.NSFWChannelRequired(mocks.MockTextChannel()), "NSFW Channel Required"], + [commands.CommandNotFound(), "Command Not Found"], + ["someWEIrdName", "some WE Ird Name"], + ], +) +def test_get_title_from_name(exception_or_str: typing.Union[Exception, str], expected_str: str): + """Test the regex works properly for the title from name.""" + result = ErrorHandler.get_title_from_name(exception_or_str) + assert expected_str == result + + +@pytest.mark.parametrize( + ["error", "title", "description"], + [ + [ + commands.UserInputError("some interesting information."), + "User Input Error", + "some interesting information.", + ], + [ + commands.MissingRequiredArgument(inspect.Parameter("SomethingSpecial", kind=1)), + "Missing Required Argument", + "SomethingSpecial is a required argument that is missing.", + ], + [ + commands.GuildNotFound("ImportantGuild"), + "Guild Not Found", + 'Guild "ImportantGuild" not found.', + ], + ], +) +@pytest.mark.asyncio +async def test_handle_user_input_error( + cog: ErrorHandler, ctx: mocks.MockContext, error: commands.UserInputError, title: str, description: str +): + """Test user input errors are handled properly. Does not test with BadUnionArgument.""" + embed = await cog.handle_user_input_error(ctx=ctx, error=error, reset_cooldown=False) + + assert title == embed.title + assert description == embed.description + + +@pytest.mark.asyncio +async def test_handle_bot_missing_perms(cog: ErrorHandler): + """ + + Test error_handler.handle_bot_missing_perms. + + There are some cases here where the bot is unable to send messages, and that should be clear. + """ + ... + + +@pytest.mark.asyncio +async def test_handle_check_failure(cog: ErrorHandler): + """ + Test check failures. + + In some cases, this method should result in calling a bot_missing_perms method + because the bot cannot send messages. + """ + ... + + +@pytest.mark.asyncio +async def test_on_command_error(cog: ErrorHandler): + """Test the general command error method.""" + ... + + +class TestErrorHandler: + """ + Test class for the error handler. The problem here is a lot of the errors need to be raised. + + Thankfully, most of them do not have extra attributes that we use, and can be easily faked. + """ + + errors = { + commands.CommandError: [ + commands.ConversionError, + { + commands.UserInputError: [ + commands.MissingRequiredArgument, + commands.TooManyArguments, + { + commands.BadArgument: [ + commands.MessageNotFound, + commands.MemberNotFound, + commands.GuildNotFound, + commands.UserNotFound, + commands.ChannelNotFound, + commands.ChannelNotReadable, + commands.BadColourArgument, + commands.RoleNotFound, + commands.BadInviteArgument, + commands.EmojiNotFound, + commands.GuildStickerNotFound, + commands.PartialEmojiConversionFailure, + commands.BadBoolArgument, + commands.ThreadNotFound, + ] + }, + commands.BadUnionArgument, + commands.BadLiteralArgument, + { + commands.ArgumentParsingError: [ + commands.UnexpectedQuoteError, + commands.InvalidEndOfQuotedStringError, + commands.ExpectedClosingQuoteError, + ] + }, + ] + }, + commands.CommandNotFound, + { + commands.CheckFailure: [ + commands.CheckAnyFailure, + commands.PrivateMessageOnly, + commands.NoPrivateMessage, + commands.NotOwner, + commands.MissingPermissions, + commands.BotMissingPermissions, + commands.MissingRole, + commands.BotMissingRole, + commands.MissingAnyRole, + commands.BotMissingAnyRole, + commands.NSFWChannelRequired, + ] + }, + commands.DisabledCommand, + commands.CommandInvokeError, + commands.CommandOnCooldown, + commands.MaxConcurrencyReached, + ] + } diff --git a/tests/test_bot.py b/tests/modmail/test_bot.py similarity index 73% rename from tests/test_bot.py rename to tests/modmail/test_bot.py index cd220cac..1fe77535 100644 --- a/tests/test_bot.py +++ b/tests/modmail/test_bot.py @@ -4,16 +4,16 @@ - import module - create a bot object """ -import asyncio import pytest from modmail.bot import ModmailBot +from tests import mocks @pytest.mark.dependency(name="create_bot") @pytest.mark.asyncio -async def test_bot_creation(): +async def test_bot_creation() -> None: """Ensure we can make a ModmailBot instance.""" bot = ModmailBot() # cleanup @@ -27,13 +27,13 @@ def bot() -> ModmailBot: ModmailBot instance. """ - bot: ModmailBot = ModmailBot() + bot: ModmailBot = mocks.MockBot() return bot @pytest.mark.dependency(depends=["create_bot"]) @pytest.mark.asyncio -async def test_bot_close(bot): +async def test_bot_close(bot: ModmailBot) -> None: """Ensure bot closes without error.""" import contextlib import io @@ -43,9 +43,3 @@ async def test_bot_close(bot): await bot.close() resp = stdout.getvalue() assert resp == "" - - -@pytest.mark.dependency(depends=["create_bot"]) -def test_bot_main(): - """Import modmail.__main__.""" - from modmail.__main__ import main diff --git a/tests/test_logs.py b/tests/modmail/test_logs.py similarity index 90% rename from tests/test_logs.py rename to tests/modmail/test_logs.py index c97957fe..97fffaf1 100644 --- a/tests/test_logs.py +++ b/tests/modmail/test_logs.py @@ -13,7 +13,7 @@ @pytest.mark.dependency(name="create_logger") -def test_create_logging(): +def test_create_logging() -> None: """Modmail logging is importable and sets root logger correctly.""" log = logging.getLogger(__name__) assert isinstance(log, ModmailLogger) @@ -31,7 +31,7 @@ def log() -> ModmailLogger: @pytest.mark.dependency(depends=["create_logger"]) -def test_notice_level(log): +def test_notice_level(log: ModmailLogger) -> None: """Test notice logging level prints a notice response.""" notice_test_phrase = "Kinda important info" stdout = io.StringIO() @@ -45,8 +45,7 @@ def test_notice_level(log): @pytest.mark.dependency(depends=["create_logger"]) -@pytest.mark.skip() -def test_trace_level(log): +def test_trace_level(log: ModmailLogger) -> None: """Test trace logging level prints a trace response.""" trace_test_phrase = "Getting in the weeds" stdout = io.StringIO() diff --git a/tests/modmail/utils/test_embeds.py b/tests/modmail/utils/test_embeds.py index aeb256ed..9de5a069 100644 --- a/tests/modmail/utils/test_embeds.py +++ b/tests/modmail/utils/test_embeds.py @@ -1,12 +1,11 @@ import discord import pytest -from discord import Colour from modmail.utils.embeds import patch_embed @pytest.mark.dependency(name="patch_embed") -def test_patch_embed(): +def test_patch_embed() -> None: """Ensure that the function changes init only after the patch is called.""" from modmail.utils.embeds import __init__ as init from modmail.utils.embeds import original_init @@ -17,7 +16,7 @@ def test_patch_embed(): @pytest.mark.dependency(depends_on="patch_embed") -def test_create_embed(): +def test_create_embed() -> None: """Test creating an embed with patched parameters works properly.""" title = "Test title" description = "Test description" @@ -49,14 +48,14 @@ def test_create_embed(): @pytest.mark.dependency(depends_on="patch_embed") -def test_create_embed_with_extra_params(): +def test_create_embed_with_extra_params() -> None: """Test creating an embed with extra parameters errors properly.""" with pytest.raises(TypeError, match="ooga_booga"): discord.Embed("hello", ooga_booga=3) @pytest.mark.dependency(depends_on="patch_embed") -def test_create_embed_with_description_and_content(): +def test_create_embed_with_description_and_content() -> None: """ Create an embed while providing both description and content parameters. diff --git a/tests/modmail/utils/test_time.py b/tests/modmail/utils/test_time.py new file mode 100644 index 00000000..53682643 --- /dev/null +++ b/tests/modmail/utils/test_time.py @@ -0,0 +1,26 @@ +import arrow +import pytest + +from modmail.utils import time as utils_time +from modmail.utils.time import TimeStampEnum + + +@pytest.mark.parametrize( + ["timestamp", "expected", "mode"], + [ + [arrow.get(1634593650), "", TimeStampEnum.SHORT_DATE_TIME], + [arrow.get(1), "", TimeStampEnum.DEFAULT], + [arrow.get(12356941), "", TimeStampEnum.RELATIVE_TIME], + [arrow.get(8675309).datetime, "", TimeStampEnum.LONG_DATE], + ], +) +def test_timestamp(timestamp, expected: str, mode: utils_time.TimeStampEnum): + """Test the timestamp is of the proper form.""" + fmtted_timestamp = utils_time.get_discord_formatted_timestamp(timestamp, mode) + assert expected == fmtted_timestamp + + +def test_enum_default(): + """Ensure that the default mode is of the correct mode, and works properly.""" + assert TimeStampEnum.DEFAULT.name == TimeStampEnum.SHORT_DATE_TIME.name + assert TimeStampEnum.DEFAULT.value == TimeStampEnum.SHORT_DATE_TIME.value diff --git a/tests/test_mocks.py b/tests/test_mocks.py new file mode 100644 index 00000000..e1b56329 --- /dev/null +++ b/tests/test_mocks.py @@ -0,0 +1,465 @@ +""" +Meta test file for tests/mocks.py. + +Original Source: +https://github.com/python-discord/bot/blob/d183d03fa2939bebaac3da49646449fdd4d00e6c/tests/test_helpers.py # noqa: E501 + +NOTE: THIS FILE WAS REWRITTEN TO USE PYTEST + + +MIT License + +Copyright (c) 2018 Python Discord + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +""" + +import asyncio +import typing +import unittest.mock + +import arrow +import discord +import discord.ext.commands +import pytest + +from tests import mocks + + +class TestDiscordMocks: + """Tests for our specialized discord.py mocks.""" + + @pytest.mark.parametrize( + ["mock_class", "counterpart", "mock_args"], + [ + [mocks.MockRole, discord.Role, {"name": "role", "position": 1, "mention": "&role"}], + [ + mocks.MockMember, + discord.Member, + { + "name": "member", + "roles": [mocks.MockRole(name="@everyone", position=1, id=0)], + "mention": "@member", + }, + ], + [ + mocks.MockGuild, + discord.Guild, + {"roles": [mocks.MockRole(name="@everyone", position=1, id=0)], "members": []}, + ], + [mocks.MockBot, discord.ext.commands.Bot, {}], + ], + ) + def test_mock_obj_default_initialization( + self, mock_class: typing.Any, counterpart: typing.Any, mock_args: dict + ): + """Test if the default initialization of a mock object results in the correct object.""" + obj = mock_class() + + # The `spec` argument makes sure `isinstance` checks with mocks pass + assert isinstance(obj, counterpart) + + for k, v in mock_args.items(): + assert getattr(obj, k) == v + + @pytest.mark.parametrize( + ["mock_class", "mock_args", "extra_mock_args"], + [ + [ + mocks.MockRole, + { + "name": "Admins", + "position": 10, + "id": mocks.generate_realistic_id(arrow.get(1517133142)), + }, + {"mention": "&Admins"}, + ], + [ + mocks.MockMember, + {"name": "arl", "id": mocks.generate_realistic_id(arrow.get(1620350090))}, + {"mention": "@arl"}, + ], + [ + mocks.MockGuild, + {"members": []}, + {"roles": [mocks.MockRole(name="@everyone", position=1, id=0)]}, + ], + [mocks.MockVoiceChannel, {}, {"mention": "#voice_channel"}], + ], + ) + def test_mock_obj_initialization_with_args( + self, mock_class: typing.Any, mock_args: dict, extra_mock_args: dict + ): + """Test if an initialization of a mock object with keywords results in the correct object.""" + obj = mock_class(**mock_args) + + mock_args.update(extra_mock_args) + for k, v in mock_args.items(): + assert v == getattr(obj, k) + + def test_mock_role_uses_position_for_less_than_greater_than(self): + """Test if `<` and `>` comparisons for MockRole are based on its position attribute.""" + role_one = mocks.MockRole(position=1) + role_two = mocks.MockRole(position=2) + role_three = mocks.MockRole(position=3) + + assert role_one < role_two + assert role_one < role_three + assert role_two < role_three + assert role_three > role_two + assert role_three > role_one + assert role_two > role_one + + def test_mock_guild_alternative_arguments(self): + """Test if MockGuild initializes with the arguments provided.""" + core_developer = mocks.MockRole(name="Core Developer", position=2) + guild = mocks.MockGuild( + roles=[core_developer], + members=[mocks.MockMember(id=54321)], + ) + + assert guild.roles == [mocks.MockRole(name="@everyone", position=1, id=0), core_developer] + assert guild.members == [mocks.MockMember(id=54321)] + + def test_mock_guild_accepts_dynamic_arguments(self): + """Test if MockGuild accepts and sets arbitrary keyword arguments.""" + guild = mocks.MockGuild( + emojis=(":hyperjoseph:", ":pensive_ela:"), + premium_subscription_count=15, + ) + + assert guild.emojis == (":hyperjoseph:", ":pensive_ela:") + assert guild.premium_subscription_count == 15 + + def test_mock_context_default_initialization(self): + """Tests if MockContext initializes with the correct values.""" + context = mocks.MockContext() + + # The `spec` argument makes sure `isinstance` checks with `discord.ext.commands.Context` pass + assert isinstance(context, discord.ext.commands.Context) + + assert isinstance(context.bot, mocks.MockBot) + assert isinstance(context.guild, mocks.MockGuild) + assert isinstance(context.author, mocks.MockMember) + assert isinstance(context.message, mocks.MockMessage) + + # ensure that the mocks are the same attributes, like discord.py + assert context.message.channel is context.channel + assert context.channel.guild is context.guild + + # ensure the me instance is of the right type and is shared among mock attributes. + assert isinstance(context.me, mocks.MockMember) + assert context.me is context.guild.me + + @pytest.mark.parametrize( + ["mock", "valid_attribute"], + [ + [mocks.MockGuild, "name"], + [mocks.MockRole, "hoist"], + [mocks.MockMember, "display_name"], + [mocks.MockBot, "user"], + [mocks.MockContext, "invoked_with"], + [mocks.MockTextChannel, "last_message"], + [mocks.MockMessage, "mention_everyone"], + ], + ) + def test_mocks_allows_access_to_attributes_part_of_spec(self, mock, valid_attribute: str): + """Accessing attributes that are valid for the objects they mock should succeed.""" + mock = mock() + try: + getattr(mock, valid_attribute) + except AttributeError: # pragma: nocover + msg = f"accessing valid attribute `{valid_attribute}` raised an AttributeError" + pytest.fail(msg) + + @pytest.mark.parametrize( + ["mock"], + [ + [mocks.MockBot], + [mocks.MockCategoryChannel], + [mocks.MockContext], + [mocks.MockClientUser], + [mocks.MockDMChannel], + [mocks.MockGuild], + [mocks.MockMember], + [mocks.MockMessage], + [mocks.MockRole], + [mocks.MockTextChannel], + [mocks.MockThread], + [mocks.MockUser], + [mocks.MockVoiceChannel], + ], + ) + def test_mocks_rejects_access_to_attributes_not_part_of_spec(self, mock): + """Accessing attributes that are invalid for the objects they mock should fail.""" + mock = mock() + with pytest.raises(AttributeError): + mock.the_cake_is_a_lie + + @pytest.mark.parametrize( + ["mock_type", "provided_mention"], + [ + [mocks.MockClientUser, "client_user mention"], + [mocks.MockMember, "member mention"], + [mocks.MockRole, "role mention"], + [mocks.MockTextChannel, "channel mention"], + [mocks.MockThread, "thread mention"], + [mocks.MockUser, "user mention"], + ], + ) + def test_mocks_use_mention_when_provided_as_kwarg(self, mock_type, provided_mention: str): + """The mock should use the passed `mention` instead of the default one if present.""" + mock = mock_type(mention=provided_mention) + assert mock.mention == provided_mention + + def test_create_test_on_mock_bot_closes_passed_coroutine(self): + """`bot.loop.create_task` should close the passed coroutine object to prevent warnings.""" + + async def dementati(): # pragma: nocover + """Dummy coroutine for testing purposes.""" + pass + + coroutine_object = dementati() + + bot = mocks.MockBot() + bot.loop.create_task(coroutine_object) + with pytest.raises(RuntimeError) as error: + asyncio.run(coroutine_object) + assert error.match("cannot reuse already awaited coroutine") + + +HASHABLE_MOCKS = ( + mocks.MockRole, + mocks.MockMember, + mocks.MockGuild, + mocks.MockTextChannel, + mocks.MockVoiceChannel, +) + + +class TestMockObjects: + """Tests the mock objects and mixins we've defined.""" + + def test_colour_mixin(self): + """Test if the ColourMixin adds aliasing of color -> colour for child classes.""" + + class MockHemlock(unittest.mock.MagicMock, mocks.ColourMixin): + pass + + hemlock = MockHemlock() + hemlock.color = 1 + assert hemlock.colour == 1 + assert hemlock.colour == hemlock.color + + hemlock.accent_color = 123 + assert hemlock.accent_colour == 123 + assert hemlock.accent_colour == hemlock.accent_color + + def test_hashable_mixin_hash_returns_id(self): + """Test the HashableMixing uses the id attribute for hashing.""" + + class MockScragly(unittest.mock.Mock, mocks.HashableMixin): + pass + + scragly = MockScragly() + scragly.id = 10 << 22 + assert hash(scragly) == scragly.id >> 22 + + def test_hashable_mixin_uses_id_for_equality_comparison(self): + """Test the HashableMixing uses the id attribute for equal comparison.""" + + class MockScragly(mocks.HashableMixin): + pass + + scragly = MockScragly() + scragly.id = 10 + eevee = MockScragly() + eevee.id = 10 + python = MockScragly() + python.id = 20 + + assert scragly == eevee + assert (scragly == python) is False + + def test_hashable_mixin_uses_id_for_inequality_comparison(self): + """Test if the HashableMixing uses the id attribute for non-equal comparison.""" + + class MockScragly(mocks.HashableMixin): + pass + + scragly = MockScragly() + scragly.id = 10 + eevee = MockScragly() + eevee.id = 10 + python = MockScragly() + python.id = 20 + + assert scragly != python + assert (scragly != eevee) is False + + @pytest.mark.parametrize(["mock_cls"], [[x] for x in HASHABLE_MOCKS]) + def test_mock_class_with_hashable_mixin_uses_id_for_hashing(self, mock_cls): + """Test if the MagicMock subclasses that implement the HashableMixin use id bitshift for hash.""" + instance = mock_cls(id=100 << 22) + assert hash(instance) == instance.id >> 22 + + @pytest.mark.parametrize(["mock_class"], [[x] for x in HASHABLE_MOCKS]) + def test_mock_class_with_hashable_mixin_uses_id_for_equality(self, mock_class): + """Test if MagicMocks that implement the HashableMixin use id for equality comparisons.""" + instance_one = mock_class() + instance_two = mock_class() + instance_three = mock_class() + + instance_one.id = 10 + instance_two.id = 10 + instance_three.id = 20 + + assert instance_one == instance_two + assert (instance_one == instance_three) is False + + @pytest.mark.parametrize(["mock_class"], [[x] for x in HASHABLE_MOCKS]) + def test_mock_class_with_hashable_mixin_uses_id_for_nonequality(self, mock_class): + """Test if MagicMocks that implement HashableMixin use id for nonequality comparisons.""" + instance_one = mock_class() + instance_two = mock_class() + instance_three = mock_class() + + instance_one.id = 10 + instance_two.id = 10 + instance_three.id = 20 + + assert instance_one != instance_three + assert (instance_one != instance_two) is False + + def test_custom_mock_mixin_accepts_mock_seal(self): + """The `CustomMockMixin` should support `unittest.mock.seal`.""" + + class MyMock(mocks.CustomMockMixin, unittest.mock.MagicMock): + + child_mock_type = unittest.mock.MagicMock + pass + + mock = MyMock() + unittest.mock.seal(mock) + with pytest.raises(AttributeError) as error: + mock.shirayuki = "hello!" + + assert error.match("shirayuki") + + @pytest.mark.parametrize( + ["mock_type", "valid_attribute"], + [ + (mocks.MockGuild, "region"), + (mocks.MockRole, "mentionable"), + (mocks.MockMember, "display_name"), + (mocks.MockBot, "owner_id"), + (mocks.MockContext, "command_failed"), + (mocks.MockMessage, "mention_everyone"), + (mocks.MockEmoji, "managed"), + (mocks.MockPartialEmoji, "url"), + (mocks.MockReaction, "me"), + ], + ) + def test_spec_propagation_of_mock_subclasses(self, mock_type, valid_attribute: str): + """Test if the `spec` does not propagate to attributes of the mock object.""" + mock = mock_type() + assert isinstance(mock, mock_type) + attribute = getattr(mock, valid_attribute) + assert isinstance(attribute, mock_type.child_mock_type) + + def test_custom_mock_mixin_mocks_async_magic_methods_with_async_mock(self): + """The CustomMockMixin should mock async magic methods with an AsyncMock.""" + + class MyMock(mocks.CustomMockMixin, unittest.mock.MagicMock): + pass + + mock = MyMock() + assert isinstance(mock.__aenter__, unittest.mock.AsyncMock) + + +class TestReturnTypes: + """ + Our mocks are designed to automatically return the correct objects based on certain methods. + + Eg, ctx.send should return a message object. + """ + + @pytest.mark.parametrize( + "mock_cls", + [ + mocks.MockClientUser, + mocks.MockGuild, + mocks.MockMember, + mocks.MockMessage, + mocks.MockTextChannel, + mocks.MockVoiceChannel, + mocks.MockWebhook, + ], + ) + @pytest.mark.asyncio + async def test_edit_returns_same_class(self, mock_cls): + """Edit methods return a new instance of the same type.""" + mock = mock_cls() + + new_mock = await mock.edit() + + assert isinstance(new_mock, type(mock_cls.spec_set)) + + @pytest.mark.parametrize( + "mock_cls", + [ + mocks.MockMember, + mocks.MockTextChannel, + mocks.MockThread, + mocks.MockUser, + ], + ) + @pytest.mark.asyncio + async def test_messageable_send_returns_message(self, mock_cls): + """Ensure that channel objects return mocked messages when sending messages.""" + messageable = mock_cls() + + msg = await messageable.send("hi") + + print(type(msg)) + assert isinstance(msg, discord.Message) + + @pytest.mark.parametrize( + "mock_cls", + [mocks.MockMessage, mocks.MockTextChannel], + ) + @pytest.mark.asyncio + async def test_thread_create_returns_thread(self, mock_cls): + """Thread create methods should return a MockThread.""" + mock = mock_cls() + + thread = await mock.create_thread() + + assert isinstance(thread, discord.Thread) + + +class TestMocksNotCallable: + """All discord.py mocks are not callable objects, so the mocks should not be either .""" + + @pytest.mark.parametrize("factory", mocks.COPYABLE_MOCKS.values()) + def test_not_callable(self, factory): + """Assert all mocks aren't callable.""" + instance = factory() + with pytest.raises(TypeError, match=f"'{type(instance).__name__}' object is not callable"): + instance()