diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 0fe3702d7d..ba4eeca6f9 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -26,13 +26,13 @@ jobs: with: python-version: ${{ matrix.python-version }} cache: "pip" - cache-dependency-path: "requirements/dev.txt" + cache-dependency-path: "requirements/test.txt" check-latest: true - name: Install dependencies run: | python -m pip install --upgrade pip pip install flake8 - pip install -r requirements/dev.txt + pip install -r requirements/test.txt - name: Setup cache id: cache-pytest uses: actions/cache@v3 @@ -47,8 +47,7 @@ jobs: flake8 . --count --exit-zero --max-complexity=10 --max-line-length=120 --statistics - name: Run code coverage with pytest run: | - coverage run -m pytest - coverage xml + pytest - name: Upload code coverage to codecov.io uses: codecov/codecov-action@v3 with: diff --git a/.gitignore b/.gitignore index f45b1f8803..f524fe583a 100644 --- a/.gitignore +++ b/.gitignore @@ -184,5 +184,10 @@ __pycache__ test.py node_modules/* +# Tests +!discord/ext/testing/mock_responses/*.json +!tests/assets/*.png +!tests/assets/*.json + # changelog is autogenerated from CHANGELOG.md docs/changelog.md diff --git a/README.rst b/README.rst index 8d5a9170c6..963bcb0f90 100644 --- a/README.rst +++ b/README.rst @@ -16,6 +16,9 @@ Pycord .. image:: https://img.shields.io/github/v/release/Pycord-Development/pycord?include_prereleases&label=Latest%20Release&logo=github&sort=semver&style=for-the-badge&logoColor=white :target: https://github.com/Pycord-Development/pycord/releases :alt: Latest release +.. image:: https://img.shields.io/codecov/c/github/Pycord-Development/pycord?style=for-the-badge&token=15RRquov0F + :target: https://codecov.io/gh/Pycord-Development/pycord + :alt: Codecov A fork of discord.py. Pycord is a modern, easy to use, feature-rich, and async ready API wrapper for Discord written in Python. diff --git a/codecov.yml b/codecov.yml index 651ae0537e..b4282a565e 100644 --- a/codecov.yml +++ b/codecov.yml @@ -6,20 +6,22 @@ coverage: status: project: default: - target: 0% + target: auto + threshold: 1% + paths: + - discord http: - target: 0% - flags: - - http + target: 55% + paths: + - tests/http + - discord/http.py patch: default: - target: 0% + target: 90% + paths: + - discord http: - target: 0% - flags: - - http - -flags: - http: - paths: - - discord/http.py + target: 100% + paths: + - tests/http + - discord/http.py diff --git a/tests/helpers.py b/discord/ext/testing/__init__.py similarity index 89% rename from tests/helpers.py rename to discord/ext/testing/__init__.py index 843ddf078e..4e259c1722 100644 --- a/tests/helpers.py +++ b/discord/ext/testing/__init__.py @@ -1,7 +1,6 @@ """ The MIT License (MIT) -Copyright (c) 2015-2021 Rapptz Copyright (c) 2021-present Pycord Development Permission is hereby granted, free of charge, to any person obtaining a @@ -22,10 +21,5 @@ FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. """ -from typing import TypeVar -V = TypeVar("V") - - -async def coroutine(val: V) -> V: - return val +from .core import * diff --git a/discord/ext/testing/core.py b/discord/ext/testing/core.py new file mode 100644 index 0000000000..5ba4e4f121 --- /dev/null +++ b/discord/ext/testing/core.py @@ -0,0 +1,153 @@ +""" +The MIT License (MIT) + +Copyright (c) 2021-present Pycord Development + +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 inspect +import json +import os +from typing import Any, Callable, Iterable, Literal, Sequence +from unittest.mock import AsyncMock, MagicMock, patch + +from ... import MISSING, File +from ...client import Client +from ...http import HTTPClient, Route + +__all__ = ( + "Test", + "Mocked", + "get_mock_response", +) + + +def get_mock_response(name: str) -> dict[str, Any]: + with open( + os.path.join(os.path.dirname(__file__), "mock_responses", f"{name}.json") + ) as f: + return json.load(f) + + +class Mocked: + def __init__( + self, + name: str, + return_value: Any | Callable[[...], Any] | None = None, + approach: Literal["merge", "replace"] = "replace", + ): + self.name = name + self.mock: MagicMock | AsyncMock | None = None + + if callable(return_value): + + async def wrapped(*args, **kwargs): + inner = return_value(*args, **kwargs) + if inspect.isawaitable(inner): + inner = await inner + if approach == "merge": + inner = dict(**get_mock_response(self.name), **inner) + return inner + + self.patcher = patch.object(HTTPClient, self.name, wrapped) + self.return_value = None + else: + if return_value is None: + return_value = {} + approach = "merge" + if approach == "merge": + return_value = dict(**get_mock_response(self.name), **return_value) + self.patcher = patch.object(HTTPClient, self.name, autospec=True) + self.return_value = return_value + + def __enter__(self) -> MagicMock | AsyncMock: + self.mock = self.patcher.start() + if self.return_value is not None: + + async def _coro(): + return self.return_value + + self.mock.return_value = _coro() + return self.mock + + def __exit__(self, exc_type, exc_val, exc_tb): + self.patcher.stop() + + +class Test: + http: HTTPClient + + def __init__(self, client: Client): + self.__client = client + + def patch( + self, + name: str, + return_value: Any | Callable[[...], Any] | None = None, + approach: Literal["merge", "replace"] = "replace", + ) -> Mocked: + return Mocked(name, return_value, approach) + + def makes_request( + self, + route: Route, + *, + files: Sequence[File] | None | MISSING = MISSING, + form: Iterable[dict[str, Any]] | None | MISSING = MISSING, + side_effect: Any | Callable[[...], Any] | None = None, + **kwargs: Any, + ): + class _Request: + def __init__( + self, client: Client, route, files, form, *, side_effect, **kwargs + ): + if side_effect is None: + self.patcher = patch.object(client.http, "request", autospec=True) + else: + self.patcher = patch.object( + client.http, "request", side_effect=side_effect + ) + self.route = route + self.files = files + self.form = form + self.kwargs = kwargs + + def __enter__(self): + self.mock = self.patcher.start() + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + self.patcher.stop() + if self.form is not MISSING: + self.kwargs["form"] = self.form + if self.files is not MISSING: + self.kwargs["files"] = self.files + self.mock.assert_called_once_with( + self.route, + **self.kwargs, + ) + + return _Request( + self.__client, route, files, form, side_effect=side_effect, **kwargs + ) + + def __getattr__(self, name: str) -> Any: + return getattr(self.__client, name) diff --git a/discord/ext/testing/fixtures/__init__.py b/discord/ext/testing/fixtures/__init__.py new file mode 100644 index 0000000000..571d635520 --- /dev/null +++ b/discord/ext/testing/fixtures/__init__.py @@ -0,0 +1,25 @@ +""" +The MIT License (MIT) + +Copyright (c) 2021-present Pycord Development + +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 .core import * +from .http import * diff --git a/discord/ext/testing/fixtures/core.py b/discord/ext/testing/fixtures/core.py new file mode 100644 index 0000000000..06fef27937 --- /dev/null +++ b/discord/ext/testing/fixtures/core.py @@ -0,0 +1,214 @@ +""" +The MIT License (MIT) + +Copyright (c) 2021-present Pycord Development + +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 random + +import pytest + +from discord.types import components +from discord.types import embed as embed_type +from discord.types import message, sticker + +from ..helpers import ( + random_allowed_mentions, + random_amount, + random_bool, + random_bytes, + random_embed, + random_snowflake, + random_snowflake_list, + random_sticker, + random_string, +) + +__all__ = ( + "user_id", + "channel_id", + "guild_id", + "message_id", + "message_ids", + "reason", + "limit", + "after", + "before", + "around", + "emoji", + "name", + "invitable", + "content", + "embed", + "embeds", + "nonce", + "allowed_mentions", + "stickers", + "components", + "avatar", + "applied_tags", + "icon", +) + + +@pytest.fixture +def user_id() -> int: + """A random user ID fixture.""" + return random_snowflake() + + +@pytest.fixture +def channel_id() -> int: + """A random channel ID fixture.""" + return random_snowflake() + + +@pytest.fixture +def guild_id() -> int: + """A random guild ID fixture.""" + return random_snowflake() + + +@pytest.fixture +def message_id() -> int: + """A random message ID fixture.""" + return random_snowflake() + + +@pytest.fixture +def message_ids() -> list[int]: + """A random amount of message IDs fixture.""" + return random_amount(random_snowflake) + + +@pytest.fixture(params=[None, "random"]) +def reason(request) -> str: + """A random reason fixture.""" + if request.param == "random": + return random_string() + return request.param + + +@pytest.fixture +def limit() -> int: + """A random limit fixture.""" + return random.randrange(0, 1000) + + +@pytest.fixture(params=(None, "random")) +def after(request) -> int | None: + """A random after fixture.""" + if request.param == "random": + return random_snowflake() + return None + + +@pytest.fixture(params=(None, "random")) +def before(request) -> int | None: + """A random before fixture.""" + if request.param == "random": + return random_snowflake() + return None + + +@pytest.fixture(params=(None, "random")) +def around(request) -> int | None: + """A random around fixture.""" + if request.param == "random": + return random_snowflake() + return None + + +@pytest.fixture +def emoji() -> str: + """A random emoji fixture.""" + return "👍" # TODO: Randomize emoji fixture + + +@pytest.fixture +def name() -> str | None: + return random_string() + + +@pytest.fixture +def invitable() -> bool: + # Only checks one case to shorten tests + return random_bool() + + +@pytest.fixture(params=(None, "random")) +def content(request) -> str | None: + if request.param == "random": + return random_string() + return None + + +@pytest.fixture(name="embed", params=(None, random_embed())) +def embed(request) -> embed_type.Embed | None: + return request.param + + +@pytest.fixture(params=(None, random_amount(random_embed))) +def embeds(request) -> list[embed_type.Embed] | None: + return request.param + + +@pytest.fixture(params=(None, "...")) # TODO: Replace string value +def nonce(request) -> str | None: + return request.param + + +@pytest.fixture(params=(None, random_allowed_mentions())) +def allowed_mentions(request) -> message.AllowedMentions | None: + return request.param + + +@pytest.fixture(params=(None, [], random_amount(random_sticker))) +def stickers(request) -> list[sticker.StickerItem] | None: + return request.param + + +@pytest.fixture(params=(None,)) # TODO: Add components to tests +def components(request) -> components.Component | None: + return request.param + + +@pytest.fixture(params=(None, "random")) +def avatar(request) -> bytes | None: + if request.param == "random": + return random_bytes() + return None + + +@pytest.fixture(params=(None, "random")) +def icon(request) -> bytes | None: + """A random icon fixture""" + if request.param == "random": + return random_bytes() + return None + + +@pytest.fixture(params=(None, "random")) +def applied_tags(request) -> list[int] | None: + if request.param == "random": + return random_snowflake_list() + return None diff --git a/discord/ext/testing/fixtures/http.py b/discord/ext/testing/fixtures/http.py new file mode 100644 index 0000000000..4aed19e4fc --- /dev/null +++ b/discord/ext/testing/fixtures/http.py @@ -0,0 +1,23 @@ +""" +The MIT License (MIT) + +Copyright (c) 2021-present Pycord Development + +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. +""" diff --git a/discord/ext/testing/helpers.py b/discord/ext/testing/helpers.py new file mode 100644 index 0000000000..476c538d8e --- /dev/null +++ b/discord/ext/testing/helpers.py @@ -0,0 +1,236 @@ +""" +The MIT License (MIT) + +Copyright (c) 2021-present Pycord Development + +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 random +import string +import sys +from io import BytesIO +from itertools import chain, combinations +from typing import Callable, Iterable, TypeVar, get_args + +from discord import File +from discord.types import channel, embed, message, sticker, threads + +if sys.version_info >= (3, 10): + from typing import ParamSpec +else: + from typing_extensions import ParamSpec + +__all__ = ( + "powerset", + "random_combination", + "random_snowflake", + "random_embed", + "random_bool", + "random_allowed_mentions", + "random_message_reference", + "random_sticker", + "random_file", + "random_count", + "random_amount", + "random_overwrite", + "random_dict", + "random_archive_duration", + "random_bytes", + "random_string", +) + +V = TypeVar("V") + + +def powerset(iterable: Iterable[V]) -> Iterable[Iterable[V]]: + # https://docs.python.org/3/library/itertools.html#itertools-recipes + """powerset([1,2,3]) --> () (1,) (2,) (3,) (1,2) (1,3) (2,3) (1,2,3)""" + s = list(iterable) + return chain.from_iterable(combinations(s, r) for r in range(len(s) + 1)) + + +def random_combination(iterable: Iterable[V]) -> Iterable[V]: + """Generate a random combination of the given iterable. + + Parameters + ---------- + iterable: Iterable[V] + The iterable to generate a combination from. + + Returns + ------- + Iterable[V] + The generated combination. + """ + return random.choice(list(powerset(iterable))) + + +def random_snowflake() -> int: + """Generate a random snowflake.""" + return random.randint(0, 2**64 - 1) + + +def random_embed() -> embed.Embed: + """Generate a random embed object.""" + # TODO: Improve this + return embed.Embed( + title="Test", + ) + + +def random_bool() -> bool: + """Generate a random boolean.""" + return random.random() > 0.5 + + +def random_allowed_mentions() -> message.AllowedMentions: + """Generate a random allowed mentions object.""" + parse: list[message.AllowedMentionType] = ["roles", "users", "everyone"] + return message.AllowedMentions( + parse=list(random_combination(parse)), + roles=random_amount(random_snowflake) if random_bool() else None, + users=random_amount(random_snowflake) if random_bool() else None, + replied_user=random.random() > 0.5, + ) + + +def random_message_reference() -> message.MessageReference: + """Generate a random message reference object.""" + return message.MessageReference( + message_id=random_snowflake() if random_bool() else None, + channel_id=random_snowflake() if random_bool() else None, + guild_id=random_snowflake() if random_bool() else None, + fail_if_not_exists=random.random() > 0.5 if random_bool() else None, + ) + + +def random_sticker() -> sticker.StickerItem: + """Generate a random sticker object.""" + return sticker.StickerItem( + id=random_snowflake(), + name="test", + format_type=random.choice([1, 2, 3]), + ) + + +def random_file(filename: str = "test.txt") -> File: # TODO: Improve random_file helper + """Generate a random file object.""" + buf = BytesIO(random_bytes()) + return File(buf, filename=filename) + + +def random_count(maximum: int = 10) -> int: + """Generate a random number from 1 to ``maximum``, inclusive. + + Parameters + ---------- + maximum: int + The maximum number to generate. Defaults to 10. + + ..note:: + This is equivalent to ``random.randint(1, maximum)``. + + Returns + ------- + int + The generated number. + """ + return random.randint(1, maximum) + + +P = ParamSpec("P") +T = TypeVar("T") + + +def random_amount( + func: Callable[P, T], maximum: int = 10, args: P.args = (), kwargs: P.kwargs = None +) -> list[T]: + """Generate a random amount of the given function. + + Parameters + ---------- + func: Callable[P, T] + The function to generate a random amount of. + maximum: int + The maximum amount to generate. Defaults to 10. + *args: P.args + The arguments to pass to the function. + **kwargs: P.kwargs + The keyword arguments to pass to the function. + + Returns + ------- + list[T] + The generated list of values. + """ + if kwargs is None: + kwargs = {} + return [func(*args, **kwargs) for _ in range(random_count(maximum))] + + +def random_dict(*, keys: Iterable[str] | None = None) -> dict[str, str | int | bool]: + """Generate a random dictionary.""" + T = TypeVar("T", str, int, bool) + value_type: T = random.choice([str, int, bool]) + + def value(key: str) -> T: + if value_type is str: + return f"value test{key}" + elif value_type is int: + return random.randrange(0, 100) + elif value_type is bool: + return random_bool() + else: + raise TypeError(f"Unknown value type: {value_type}") + + if keys is None: + keys = [f"test{i}" for i in range(random_count())] + return {random_string(): value(key) for key in keys} + + +def random_overwrite() -> channel.PermissionOverwrite: + """Generate a random overwrite.""" + return channel.PermissionOverwrite( + id=random_snowflake(), + type=random.choice(get_args(channel.OverwriteType)), + allow=str(random_snowflake()), + deny=str(random_snowflake()), + ) + + +def random_archive_duration() -> threads.ThreadArchiveDuration: + """Generate a random archive duration.""" + return random.choice(get_args(threads.ThreadArchiveDuration)) + + +def random_bytes(length: int = 10) -> bytes: + """Generate random bytes""" + return random_string(length).encode() + + +def random_string(length: int = 10) -> str: + """Generate a random string""" + letters = string.ascii_letters + string.digits # + string.punctuation + return "".join(random.choices(list(letters), k=length)) + + +def random_snowflake_list() -> list[int]: + return random_amount(random_snowflake) diff --git a/discord/ext/testing/mock_responses/get_guild.json b/discord/ext/testing/mock_responses/get_guild.json new file mode 100644 index 0000000000..6a003ad36d --- /dev/null +++ b/discord/ext/testing/mock_responses/get_guild.json @@ -0,0 +1,117 @@ +{ + "id": "881207955029110855", + "name": "Pycord", + "icon": "a_4fe704fc022c0ebaa8077c0c89f59840", + "description": "My cool description.", + "splash": "497cda195351cdf76d7fbc0a43ce4765", + "discovery_splash": "6c468a08e0a663ca717b0e4e3ff47f7e", + "features": [ + "COMMUNITY", + "THREADS_ENABLED", + "BANNER", + "AUTO_MODERATION", + "THREADS_ONLY_CHANNEL", + "ANIMATED_BANNER", + "VIP_REGIONS", + "MEMBER_PROFILES", + "SEVEN_DAY_THREAD_ARCHIVE", + "WELCOME_SCREEN_ENABLED", + "NEW_THREAD_PERMISSIONS", + "PREVIEW_ENABLED", + "VANITY_URL", + "NEWS", + "DISCOVERABLE", + "ENABLED_DISCOVERABLE_BEFORE", + "PRIVATE_THREADS", + "PARTNERED", + "INVITE_SPLASH", + "THREE_DAY_THREAD_ARCHIVE", + "TEXT_IN_VOICE_ENABLED", + "ANIMATED_ICON", + "ROLE_ICONS", + "GUILD_HOME_TEST", + "MEMBER_VERIFICATION_GATE_ENABLED" + ], + "approximate_member_count": 100, + "approximate_presence_count": 50, + "emojis": [ + { + "name": "Python", + "roles": [], + "id": "881421088763047946", + "require_colons": true, + "managed": false, + "animated": false, + "available": true + } + ], + "stickers": [ + { + "id": "881476594680619008", + "name": "Pycord", + "tags": "computer", + "type": 2, + "format_type": 1, + "description": "Pycord Official Logo", + "asset": "", + "available": true, + "guild_id": "881207955029110855" + } + ], + "banner": "c826b6149d856ab8fd11101b8605038c", + "owner_id": "690420846774321221", + "application_id": null, + "region": "us-south", + "afk_channel_id": null, + "afk_timeout": 300, + "system_channel_id": "881224361015672863", + "widget_enabled": true, + "widget_channel_id": "881224361015672863", + "verification_level": 2, + "roles": [ + { + "id": "881207955029110855", + "name": "@everyone", + "permissions": "1002979053249", + "position": 0, + "color": 0, + "hoist": false, + "managed": false, + "mentionable": false, + "icon": null, + "unicode_emoji": null, + "flags": 0 + }, + { + "id": "881223795501846558", + "name": "Admin", + "permissions": "521942847049", + "position": 1, + "color": 0, + "hoist": false, + "managed": false, + "mentionable": false, + "icon": null, + "unicode_emoji": null, + "flags": 0 + } + ], + "default_message_notifications": 1, + "mfa_level": 1, + "explicit_content_filter": 2, + "max_presences": null, + "max_members": 500000, + "max_stage_video_channel_users": 0, + "max_video_channel_users": 25, + "vanity_url_code": "pycord", + "premium_tier": 3, + "premium_subscription_count": 17, + "system_channel_flags": 8, + "preferred_locale": "en-US", + "rules_channel_id": "881224777551994920", + "public_updates_channel_id": "884028209417560084", + "hub_type": null, + "premium_progress_bar_enabled": true, + "nsfw": false, + "nsfw_level": 0 +} diff --git a/discord/http.py b/discord/http.py index d70380cd4f..deb48b3d36 100644 --- a/discord/http.py +++ b/discord/http.py @@ -121,6 +121,20 @@ def __init__(self, method: str, path: str, **parameters: Any) -> None: self.webhook_id: Snowflake | None = parameters.get("webhook_id") self.webhook_token: str | None = parameters.get("webhook_token") + def __eq__(self, other: object) -> bool: + if not isinstance(other, Route): + return False + + return ( + self.path == other.path + and self.method == other.method + and self.url == other.url + and self.channel_id == other.channel_id + and self.guild_id == other.guild_id + and self.webhook_id == other.webhook_id + and self.webhook_token == other.webhook_token + ) + @property def base(self) -> str: return f"https://discord.com/api/v{API_VERSION}" diff --git a/pyproject.toml b/pyproject.toml index e58300e470..308c3890cb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -101,4 +101,11 @@ indent-string = ' ' max-line-length = 120 [tool.pytest.ini_options] +addopts = "--cov=discord --cov-branch --cov-report=xml --cov-report=term" asyncio_mode = "auto" + +[tool.coverage.report] +exclude_lines = [ + "pragma: no cover", + "if TYPE_CHECKING:", +] diff --git a/requirements/test.txt b/requirements/test.txt new file mode 100644 index 0000000000..d9fca0daa8 --- /dev/null +++ b/requirements/test.txt @@ -0,0 +1,8 @@ +-r _.txt +pytest==7.2.0 +pytest-cov==4.0.0 +pytest-asyncio==0.20.2 +pytest-randomly==3.12.0 +coverage[toml]==6.5.0 +# pytest-mock==3.10.0 +# pytest-order==1.0.1 diff --git a/tests/README.md b/tests/README.md new file mode 100644 index 0000000000..03cae29b2d --- /dev/null +++ b/tests/README.md @@ -0,0 +1,26 @@ +# Unit Testing in PyCord + +## Scope + +Currently, only the internal framework can be unit tested, but plans are in place for +testing your applications + +## Setup + +First install all requirements from the requirements.txt file in this directory + +### On GNU/Linux systems + +The testing system uses GNU make as the interface, if you use the GNU coreutils, make is +likely already there, if not, consult your distro's documentation to get it + +In order to read the data of requests, you must generate some key files for the proxy, +you can do this by running `make gencerts` + +### On Windows systems + +A powershell script is in the works, but for now only a Makefile is available + +## Running Tests + +Execute the command `make runtests` to run the tests diff --git a/tests/__init__.py b/tests/__init__.py index e69de29bb2..4aed19e4fc 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -0,0 +1,23 @@ +""" +The MIT License (MIT) + +Copyright (c) 2021-present Pycord Development + +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. +""" diff --git a/tests/assets/test.json b/tests/assets/test.json new file mode 100644 index 0000000000..57b9e24a4e --- /dev/null +++ b/tests/assets/test.json @@ -0,0 +1,319 @@ +{ + "v": "5.6.6", + "ip": 0, + "op": 1, + "fr": 60, + "w": 111, + "h": 112, + "layers": [ + { + "ind": 2613, + "nm": "surface16045", + "ao": 0, + "ip": 0, + "op": 60, + "st": 0, + "ty": 4, + "ks": { + "ty": "tr", + "o": { "k": 100 }, + "r": { "k": 0 }, + "p": { "k": [0, 0] }, + "a": { "k": [0, 0] }, + "s": { "k": [133.14, 132.87] }, + "sk": { "k": 0 }, + "sa": { "k": 0 } + }, + "shapes": [ + { + "ty": "gr", + "hd": false, + "nm": "surface16045", + "it": [ + { + "ty": "gr", + "hd": false, + "it": [ + { + "ty": "gr", + "hd": false, + "it": [ + { + "ty": "sh", + "ks": { + "k": { + "i": [ + [0, 0], + [0, 0], + [6.69, 0], + [0, 0], + [0, -5.51], + [0, 0], + [-5.32, -1.56], + [-7.62, 2.21], + [0, 5.8], + [0, 0], + [0, 0], + [0, 0], + [0, 0], + [-2.03, 6.12], + [2.01, 8.08], + [5.85, 0] + ], + "o": [ + [0, 0], + [0, 6.92], + [0, 0], + [-5.5, 0], + [0, 0], + [0, 5.45], + [6.37, 1.87], + [5.06, -1.46], + [0, 0], + [0, 0], + [0, 0], + [0, 0], + [5.84, 0], + [2.1, -6.3], + [-1.45, -5.82], + [0, 0] + ], + "v": [ + [64.23, 21.49], + [64.23, 30.42], + [51.66, 43.17], + [31.58, 43.17], + [21.52, 53.39], + [21.52, 72.54], + [31.58, 82.76], + [51.66, 82.76], + [61.72, 72.54], + [61.72, 64.88], + [41.63, 64.88], + [41.63, 62.32], + [71.77, 62.32], + [81.83, 52.12], + [81.83, 31.69], + [71.77, 21.49] + ], + "c": true + } + } + }, + { + "ty": "sh", + "ks": { + "k": { + "i": [ + [0, 0], + [0, -2.11], + [2.09, 0], + [0, 2.12], + [-2.08, 0] + ], + "o": [ + [2.09, 0], + [0, 2.12], + [-2.08, 0], + [0, -2.11], + [0, 0] + ], + "v": [ + [52.93, 69.98], + [56.7, 73.8], + [52.93, 77.65], + [49.16, 73.8], + [52.93, 69.98] + ], + "c": true + } + } + }, + { + "ty": "gf", + "s": { "k": [52.45, 73.16] }, + "e": { "k": [36.02, 49.7] }, + "t": 1, + "h": { "k": 0 }, + "a": { "k": 0 }, + "g": { + "p": 2, + "k": { + "k": [0, 0.8, 0.8, 0.8, 1, 0.87, 0.87, 0.87, 0, 1, 1, 1] + } + }, + "hd": false, + "o": { "k": 100 } + }, + { + "ty": "tr", + "o": { "k": 100 }, + "r": { "k": 0 }, + "p": { "k": [0, 0] }, + "a": { "k": [0, 0] }, + "s": { "k": [100, 100] }, + "sk": { "k": 0 }, + "sa": { "k": 0 }, + "hd": false + } + ] + }, + { + "ty": "tr", + "o": { "k": 100 }, + "r": { "k": 0 }, + "p": { "k": [0, 0] }, + "a": { "k": [0, 0] }, + "s": { "k": [100, 100] }, + "sk": { "k": 0 }, + "sa": { "k": 0 }, + "hd": false + } + ] + }, + { + "ty": "gr", + "hd": false, + "it": [ + { + "ty": "sh", + "ks": { + "k": { + "i": [ + [0, 0], + [2.89, -0.51], + [0, -5.8], + [0, 0], + [0, 0], + [0, 0], + [0, 0], + [1.6, -6.68], + [-1.93, -8], + [-5.84, 0], + [0, 0], + [0, 0], + [-6.82, 0], + [0, 0], + [0, 5.61], + [0, 0], + [5.46, 0.91], + [3.44, -0.02] + ], + "o": [ + [-3.44, 0.02], + [-8.51, 1.5], + [0, 0], + [0, 0], + [0, 0], + [0, 0], + [-5.84, 0], + [-1.85, 7.66], + [1.43, 5.95], + [0, 0], + [0, 0], + [0, -6.64], + [0, 0], + [5.59, 0], + [0, 0], + [0, -5.45], + [-3.45, -0.57], + [0, 0] + ], + "v": [ + [41.19, 0], + [31.58, 0.82], + [21.52, 11.27], + [21.52, 18.94], + [41.63, 18.94], + [41.63, 21.49], + [13.98, 21.49], + [1.41, 31.69], + [1.41, 52.12], + [12.1, 62.32], + [19.02, 62.32], + [19.02, 53.13], + [31.58, 40.64], + [51.66, 40.64], + [61.72, 30.42], + [61.72, 11.27], + [51.66, 0.82], + [41.19, 0] + ], + "c": true + } + } + }, + { + "ty": "sh", + "ks": { + "k": { + "i": [ + [0, 0], + [0, -2.12], + [2.08, 0], + [0, 2.11], + [-2.09, 0] + ], + "o": [ + [2.08, 0], + [0, 2.11], + [-2.09, 0], + [0, -2.12], + [0, 0] + ], + "v": [ + [30.31, 6.16], + [34.09, 10.01], + [30.31, 13.83], + [26.54, 10.01], + [30.31, 6.16] + ], + "c": true + } + } + }, + { + "ty": "gf", + "s": { "k": [0, 0] }, + "e": { "k": [45.99, 39.95] }, + "t": 1, + "h": { "k": 0 }, + "a": { "k": 0 }, + "g": { + "p": 2, + "k": { "k": [0, 0.53, 0.61, 0.87, 1, 0.35, 0.4, 0.95, 0, 1, 1, 1] } + }, + "hd": false, + "o": { "k": 100 } + }, + { + "ty": "tr", + "o": { "k": 100 }, + "r": { "k": 0 }, + "p": { "k": [0, 0] }, + "a": { "k": [0, 0] }, + "s": { "k": [100, 100] }, + "sk": { "k": 0 }, + "sa": { "k": 0 }, + "hd": false + } + ] + }, + { + "ty": "tr", + "o": { "k": 100 }, + "r": { "k": 0 }, + "p": { "k": [0, 0] }, + "a": { "k": [0, 0] }, + "s": { "k": [100, 100] }, + "sk": { "k": 0 }, + "sa": { "k": 0 }, + "hd": false + } + ] + } + ] + } + ], + "meta": { "g": "LF SVG to Lottie" } +} diff --git a/tests/assets/test.png b/tests/assets/test.png new file mode 100644 index 0000000000..f911103cb1 Binary files /dev/null and b/tests/assets/test.png differ diff --git a/tests/assets/test.txt b/tests/assets/test.txt new file mode 100644 index 0000000000..3b18e512db --- /dev/null +++ b/tests/assets/test.txt @@ -0,0 +1 @@ +hello world diff --git a/tests/core.py b/tests/core.py new file mode 100644 index 0000000000..8ec93cb6e6 --- /dev/null +++ b/tests/core.py @@ -0,0 +1,32 @@ +""" +The MIT License (MIT) + +Copyright (c) 2021-present Pycord Development + +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 pytest + +from discord.bot import Bot +from discord.ext.testing import Test + + +@pytest.fixture +def client(event_loop): + return Test(Bot(loop=event_loop)) diff --git a/tests/ext/testing/test_main.py b/tests/ext/testing/test_main.py new file mode 100644 index 0000000000..4aed19e4fc --- /dev/null +++ b/tests/ext/testing/test_main.py @@ -0,0 +1,23 @@ +""" +The MIT License (MIT) + +Copyright (c) 2021-present Pycord Development + +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. +""" diff --git a/tests/http/__init__.py b/tests/http/__init__.py new file mode 100644 index 0000000000..4aed19e4fc --- /dev/null +++ b/tests/http/__init__.py @@ -0,0 +1,23 @@ +""" +The MIT License (MIT) + +Copyright (c) 2021-present Pycord Development + +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. +""" diff --git a/tests/http/core.py b/tests/http/core.py new file mode 100644 index 0000000000..596075802b --- /dev/null +++ b/tests/http/core.py @@ -0,0 +1,24 @@ +""" +The MIT License (MIT) + +Copyright (c) 2021-present Pycord Development + +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 diff --git a/tests/http/test_channel.py b/tests/http/test_channel.py new file mode 100644 index 0000000000..3e0b9effa9 --- /dev/null +++ b/tests/http/test_channel.py @@ -0,0 +1,305 @@ +""" +The MIT License (MIT) + +Copyright (c) 2021-present Pycord Development + +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 random +from typing import get_args + +import pytest + +from discord import Route +from discord.ext.testing.fixtures import channel_id, guild_id, invitable, name, reason +from discord.ext.testing.helpers import ( + powerset, + random_archive_duration, + random_bool, + random_count, + random_overwrite, + random_snowflake, +) +from discord.types import channel, guild, threads + +from ..core import client + + +@pytest.fixture(params=(random_snowflake(),)) +def parent_id(request) -> int: + return request.param + + +@pytest.fixture(params=("test topic",)) # TODO: Make channel topic random +def topic(request) -> str: + return request.param + + +@pytest.fixture(params=(random.randint(8000, 384000),)) +def bitrate(request) -> int: + return request.param + + +@pytest.fixture(params=(random_bool(),)) +def nsfw(request) -> bool | None: + return request.param + + +@pytest.fixture(params=(random.randint(0, 99),)) +def user_limit(request) -> int | None: + return request.param + + +@pytest.fixture(params=(random.randint(0, 99),)) +def position(request) -> int | None: + """A random position fixture.""" + # Note: This could theoretically go higher than 99 + return request.param + + +@pytest.fixture(params=(random_count(),)) +def permission_overwrites(request) -> list[channel.PermissionOverwrite] | None: + if request.param is None: + return None + return [random_overwrite() for _ in range(request.param)] + + +@pytest.fixture(params=(random.choice(get_args(channel.ChannelType)),)) +def type_(request) -> channel.ChannelType: + return request.param + + +@pytest.fixture(params=("test region",)) # TODO: Make channel region random +def rtc_region(request) -> str | None: + return request.param + + +@pytest.fixture(params=(random.choice(get_args(channel.VideoQualityMode)),)) +def video_quality_mode(request) -> channel.VideoQualityMode: + return request.param + + +@pytest.fixture(params=(random_bool(),)) +def archived(request) -> bool | None: + return request.param + + +@pytest.fixture(params=(random.choice(get_args(threads.ThreadArchiveDuration)),)) +def auto_archive_duration(request) -> threads.ThreadArchiveDuration | None: + return request.param + + +@pytest.fixture(params=(random_bool(),)) +def locked(request) -> bool | None: + return request.param + + +@pytest.fixture +def default_auto_archive_duration() -> threads.ThreadArchiveDuration | None: + return random_archive_duration() + + +@pytest.fixture(params=(random.randint(0, 21600),)) +def rate_limit_per_user(request) -> int | None: + return request.param + + +@pytest.fixture(params=powerset(["id", "position", "lock_permissions", "parent_id"])) +def channel_position_updates_payload( + request, channel_id, position, locked, parent_id +) -> list[guild.ChannelPositionUpdate]: + return [ + guild.ChannelPositionUpdate( + id=channel_id if "id" in request.param else None, + position=position if "position" in request.param else None, + lock_permissions=locked if "lock_permissions" in request.param else None, + parent_id=parent_id if "parent_id" in request.param else None, + ) + ] + + +@pytest.mark.parametrize( + "include", + [ + random.sample( + [ + "name", + "parent_id", + "topic", + "bitrate", + "nsfw", + "user_limit", + "position", + "permission_overwrites", + "rate_limit_per_user", + "type", + "rtc_region", + "video_quality_mode", + "archived", + "auto_archive_duration", + "locked", + "invitable", + "default_auto_archive_duration", + ], + i, + ) + for i in range(17) + ], +) +async def test_edit_channel( + client, + channel_id, + name, + parent_id, + topic, + bitrate, + nsfw, + user_limit, + position, + permission_overwrites, + rate_limit_per_user, + type_, + rtc_region, + video_quality_mode, + archived, + auto_archive_duration, + locked, + invitable, + default_auto_archive_duration, + reason, + include, # We use this because testing all combinations would result in 200k tests +): + payload = { + "name": name, + "parent_id": parent_id, + "topic": topic, + "bitrate": bitrate, + "nsfw": nsfw, + "user_limit": user_limit, + "position": position, + "permission_overwrites": permission_overwrites, + "rate_limit_per_user": rate_limit_per_user, + "type": type_, + "rtc_region": rtc_region, + "video_quality_mode": video_quality_mode, + "archived": archived, + "auto_archive_duration": auto_archive_duration, + "locked": locked, + "invitable": invitable, + "default_auto_archive_duration": default_auto_archive_duration, + } + payload = {k: v for k, v in payload.items() if k in include} + with client.makes_request( + Route("PATCH", "/channels/{channel_id}", channel_id=channel_id), + json=payload, + reason=reason, + ): + await client.http.edit_channel(channel_id, **payload, reason=reason) + + +async def test_bulk_channel_update( + client, + guild_id, + channel_position_updates_payload, + reason, +): + with client.makes_request( + Route("PATCH", "/guilds/{guild_id}/channels", guild_id=guild_id), + json=[channel_position_updates_payload], + reason=reason, + ): + await client.http.bulk_channel_update( + guild_id, [channel_position_updates_payload], reason=reason + ) + + +@pytest.mark.parametrize( + "include", + [ + random.sample( + ( + "name", + "parent_id", + "topic", + "bitrate", + "nsfw", + "user_limit", + "position", + "permission_overwrites", + "rate_limit_per_user", + "rtc_region", + "video_quality_mode", + "auto_archive_duration", + ), + i, + ) + for i in range(12) + ], +) +async def test_create_channel( + client, + guild_id, + type_, + name, + parent_id, + topic, + bitrate, + nsfw, + user_limit, + position, + permission_overwrites, + rate_limit_per_user, + rtc_region, + video_quality_mode, + auto_archive_duration, + reason, + include, +): + payload = { + "type": type_, + "name": name, + "parent_id": parent_id, + "topic": topic, + "bitrate": bitrate, + "nsfw": nsfw, + "user_limit": user_limit, + "position": position, + "permission_overwrites": permission_overwrites, + "rate_limit_per_user": rate_limit_per_user, + "rtc_region": rtc_region, + "video_quality_mode": video_quality_mode, + "auto_archive_duration": auto_archive_duration, + } + payload = {k: v for k, v in payload.items() if k in include} + with client.makes_request( + Route("POST", "/guilds/{guild_id}/channels", guild_id=guild_id), + json={"type": type_, **payload}, + reason=reason, + ): + await client.http.create_channel(guild_id, type_, **payload, reason=reason) + + +async def test_delete_channel(client, channel_id, reason): + with client.makes_request( + Route("DELETE", "/channels/{channel_id}", channel_id=channel_id), + reason=reason, + ): + await client.http.delete_channel(channel_id, reason=reason) diff --git a/tests/http/test_gateway.py b/tests/http/test_gateway.py new file mode 100644 index 0000000000..f1704c08fe --- /dev/null +++ b/tests/http/test_gateway.py @@ -0,0 +1,115 @@ +""" +The MIT License (MIT) + +Copyright (c) 2021-present Pycord Development + +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 functools +import random +from typing import Callable, TypedDict +from unittest.mock import MagicMock + +import pytest + +from discord import GatewayNotFound, HTTPException, Route +from discord.http import API_VERSION + +from ..core import client + + +@pytest.fixture(params=(True, False)) +def zlib(request) -> bool: + return request.param + + +@pytest.fixture(params=(None,)) +def encoding(request) -> str | None: + return request.param + + +class GetGateway(TypedDict): + url: str + + +class GetBotGateway(TypedDict): + url: str + shards: int + + +get_gateway_val = GetGateway(url="test") # TODO: Make gateway url random + + +@pytest.fixture(params=(get_gateway_val, None)) +def mocked_get_gateway(request) -> Callable[[], GetGateway] | HTTPException: + mock = MagicMock() + if (param := request.param) is None: + return HTTPException(mock, "Error") + return lambda *args: param + + +@pytest.fixture +def mocked_get_bot_gateway( + mocked_get_gateway, +) -> Callable[[], GetBotGateway] | HTTPException: + if isinstance(mocked_get_gateway, HTTPException): + return mocked_get_gateway + + cached_randint = functools.lru_cache(random.randint) + + return lambda *args: GetBotGateway(shards=cached_randint(1, 100), **get_gateway_val) + + +async def test_get_gateway(client, encoding, zlib, mocked_get_gateway) -> None: + with client.makes_request( + Route("GET", "/gateway"), + side_effect=mocked_get_gateway, + ): + coro = client.http.get_gateway(encoding=encoding, zlib=zlib) + if isinstance(mocked_get_gateway, HTTPException): + with pytest.raises(GatewayNotFound): + await coro + else: + value = await coro + mocked = mocked_get_gateway() + expected = f"{mocked['url']}?encoding={encoding}&v={API_VERSION}" + if zlib: + expected += "&compress=zlib-stream" + assert value == expected + + +async def test_get_bot_gateway(client, encoding, zlib, mocked_get_bot_gateway) -> None: + with client.makes_request( + Route("GET", "/gateway/bot"), + side_effect=mocked_get_bot_gateway, + ): + coro = client.http.get_bot_gateway(encoding=encoding, zlib=zlib) + if isinstance(mocked_get_bot_gateway, HTTPException): + with pytest.raises(GatewayNotFound): + await coro + else: + value = await coro + mocked = mocked_get_bot_gateway() + url = f"{mocked['url']}?encoding={encoding}&v={API_VERSION}" + if zlib: + url += "&compress=zlib-stream" + expected = (mocked["shards"], url) + assert value == expected diff --git a/tests/http/test_guild.py b/tests/http/test_guild.py new file mode 100644 index 0000000000..eb0aafaf16 --- /dev/null +++ b/tests/http/test_guild.py @@ -0,0 +1,600 @@ +""" +The MIT License (MIT) + +Copyright (c) 2021-present Pycord Development + +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 + +from pathlib import Path +from random import choice, randint, randrange, sample +from typing import Literal, get_args + +import pytest + +from discord import File, Route +from discord.ext.testing.fixtures import ( + after, + before, + channel_id, + guild_id, + icon, + limit, + name, + reason, + user_id, +) +from discord.ext.testing.helpers import ( + random_amount, + random_bool, + random_bytes, + random_snowflake, + random_string, +) +from discord.types.guild import GuildFeature + +from ..core import client + + +@pytest.fixture() +def code(): + return random_string(10) + + +@pytest.fixture() +def description(): + return random_string(100) + + +async def test_get_guilds(client, limit, before, after): + params = {"limit": limit} + if before: + params["before"] = before + if after: + params["after"] = after + with client.makes_request(Route("GET", "/users/@me/guilds"), params=params): + await client.http.get_guilds(limit, before, after) + + +async def test_leave_guild(client, guild_id): + with client.makes_request( + Route("DELETE", "/users/@me/guilds/{guild_id}", guild_id=guild_id) + ): + await client.http.leave_guild(guild_id) + + +@pytest.mark.parametrize( + "with_counts", + (True, False), +) +async def test_get_guild(client, guild_id, with_counts): + with client.makes_request( + Route("GET", "/guilds/{guild_id}", guild_id=guild_id), + params={"with_counts": int(with_counts)}, + ): + await client.http.get_guild(guild_id, with_counts=with_counts) + + +async def test_delete_guild(client, guild_id): + with client.makes_request(Route("DELETE", "/guilds/{guild_id}", guild_id=guild_id)): + await client.http.delete_guild(guild_id) + + +async def test_create_guild(client, name, icon): + payload = {"name": name} + if icon: + payload["icon"] = icon + with client.makes_request(Route("POST", "/guilds"), json=payload): + await client.http.create_guild(name, icon) + + +@pytest.mark.parametrize( + "channel_id1,channel_id2, channel_id3", + ((random_snowflake(), random_snowflake(), random_snowflake()),), +) +@pytest.mark.parametrize("premium_progress_bar_enabled", (True, False)) +@pytest.mark.parametrize("verification_level", (randint(0, 4),)) +@pytest.mark.parametrize("default_message_notifications", (randint(0, 1),)) +@pytest.mark.parametrize("explicit_content_filter", (randint(0, 2),)) +@pytest.mark.parametrize("splash", (random_bytes(),)) +@pytest.mark.parametrize("discovery_splash", (random_bytes(),)) +@pytest.mark.parametrize("banner", (random_bytes(),)) +@pytest.mark.parametrize("system_channel_flags", (randint(0, 1 << 4),)) +@pytest.mark.parametrize("preferred_locale", (random_string(5),)) +@pytest.mark.parametrize( + "features", + (sample(get_args(GuildFeature), randint(0, len(get_args(GuildFeature)))),), +) +@pytest.mark.parametrize("afk_timeout", (choice((60, 300, 900, 1800, 3600)),)) +async def test_edit_guild( + client, + name, + verification_level, + default_message_notifications, + explicit_content_filter, + channel_id3, + afk_timeout, + icon, + user_id, + splash, + discovery_splash, + banner, + channel_id, + system_channel_flags, + channel_id1, + channel_id2, + preferred_locale, + features, + description, + premium_progress_bar_enabled, + reason, +): + payload = { + "name": name, + "verification_level": verification_level, + "default_message_notifications": default_message_notifications, + "explicit_content_filter": explicit_content_filter, + "afk_channel_id": channel_id3, + "afk_timeout": afk_timeout, + "icon": icon, + "owner_id": user_id, + "splash": splash, + "discovery_splash": discovery_splash, + "banner": banner, + "system_channel_id": channel_id, + "system_channel_flags": system_channel_flags, + "rules_channel_id": channel_id1, + "public_updates_channel_id": channel_id2, + "preferred_locale": preferred_locale, + "features": features, + "description": description, + "premium_progress_bar_enabled": premium_progress_bar_enabled, + } + with client.makes_request( + Route("PATCH", "/guilds/{guild_id}", guild_id=guild_id), + json=payload, + reason=reason, + ): + await client.http.edit_guild( + guild_id, + reason=reason, + **payload, + ) + + +@pytest.mark.parametrize("required", [random_bool()]) +async def test_edit_guild_mfa(client, guild_id, required, reason): + payload = {"level": int(required)} + with client.makes_request( + Route("POST", "/guilds/{guild_id}/mfa", guild_id=guild_id), + json=payload, + reason=reason, + ): + await client.http.edit_guild_mfa( + guild_id, + required, + reason=reason, + ) + + +async def test_get_template(client, code): + with client.makes_request(Route("GET", "/guilds/templates/{code}", code=code)): + await client.http.get_template(code) + + +async def test_guild_templates(client, guild_id): + with client.makes_request( + Route("GET", "/guilds/{guild_id}/templates", guild_id=guild_id) + ): + await client.http.guild_templates(guild_id) + + +async def test_create_template(client, guild_id, name, icon): + payload = {"name": name, "icon": icon} + with client.makes_request( + Route("POST", "/guilds/{guild_id}/templates", guild_id=guild_id), + json=payload, + ): + await client.http.create_template(guild_id, payload) + + +async def test_sync_template(client, guild_id, code): + with client.makes_request( + Route( + "PUT", "/guilds/{guild_id}/templates/{code}", guild_id=guild_id, code=code + ) + ): + await client.http.sync_template(guild_id, code) + + +async def test_edit_template(client, guild_id, code, name, description): + payload = {"name": name, "description": description} + with client.makes_request( + Route( + "PATCH", "/guilds/{guild_id}/templates/{code}", guild_id=guild_id, code=code + ), + json=payload, + ): + await client.http.edit_template(guild_id, code, payload) + + +async def test_delete_template(client, guild_id, code): + with client.makes_request( + Route( + "DELETE", + "/guilds/{guild_id}/templates/{code}", + guild_id=guild_id, + code=code, + ) + ): + await client.http.delete_template(guild_id, code) + + +async def test_create_from_template(client, code, name, icon): + payload = {"name": name} + if icon is not None: + payload["icon"] = icon + with client.makes_request( + Route("POST", "/guilds/templates/{code}", code=code), json=payload + ): + await client.http.create_from_template(code, name, icon) + + +# limit is optional on this route +@pytest.mark.parametrize("limit", [None, randrange(0, 1000)]) +async def test_get_bans(client, guild_id, limit, before, after): + params = {} + if limit is not None: + params["limit"] = limit + if before is not None: + params["before"] = before + if after is not None: + params["after"] = after + with client.makes_request( + Route("GET", "/guilds/{guild_id}/bans", guild_id=guild_id), params=params + ): + await client.http.get_bans(guild_id, limit=limit, before=before, after=after) + + +async def test_get_ban(client, guild_id, user_id): + with client.makes_request( + Route( + "GET", + "/guilds/{guild_id}/bans/{user_id}", + guild_id=guild_id, + user_id=user_id, + ) + ): + await client.http.get_ban(user_id, guild_id) + + +async def test_get_vanity_code(client, guild_id): + with client.makes_request( + Route("GET", "/guilds/{guild_id}/vanity-url", guild_id=guild_id) + ): + await client.http.get_vanity_code(guild_id) + + +async def test_change_vanity_code(client, guild_id, code, reason): + with client.makes_request( + Route("PATCH", "/guilds/{guild_id}/vanity-url", guild_id=guild_id), + json={"code": code}, + reason=reason, + ): + await client.http.change_vanity_code(guild_id, code, reason=reason) + + +async def test_get_all_guild_channels(client, guild_id): + with client.makes_request( + Route("GET", "/guilds/{guild_id}/channels", guild_id=guild_id) + ): + await client.http.get_all_guild_channels(guild_id) + + +async def test_get_members(client, guild_id, limit, after): + payload = {"limit": limit} + if after is not None: + payload["after"] = after + with client.makes_request( + Route("GET", "/guilds/{guild_id}/members", guild_id=guild_id), params=payload + ): + await client.http.get_members(guild_id, limit=limit, after=after) + + +async def test_get_member(client, guild_id, user_id): + with client.makes_request( + Route( + "GET", + "/guilds/{guild_id}/members/{member_id}", + guild_id=guild_id, + member_id=user_id, + ) + ): + await client.http.get_member(guild_id, user_id) + + +@pytest.fixture(params=[0, randint(1, 10)]) +def roles(request) -> list[str]: + return [str(random_snowflake()) for _ in range(request.param)] + + +@pytest.fixture() +def days() -> int: + return randint(1, 30) + + +@pytest.mark.parametrize("compute_prune_count", [True, False]) +async def test_prune_members( + client, guild_id, days, compute_prune_count, roles, reason +): + payload = { + "days": days, + "compute_prune_count": "true" if compute_prune_count else "false", + } + if roles: + payload["include_roles"] = ", ".join(roles) + + with client.makes_request( + Route("POST", "/guilds/{guild_id}/prune", guild_id=guild_id), + json=payload, + reason=reason, + ): + await client.http.prune_members( + guild_id, days, compute_prune_count, roles, reason=reason + ) + + +async def test_estimate_pruned_members(client, guild_id, days, roles): + payload = {"days": days} + if roles: + payload["include_roles"] = ", ".join(roles) + + with client.makes_request( + Route("GET", "/guilds/{guild_id}/prune", guild_id=guild_id), params=payload + ): + await client.http.estimate_pruned_members(guild_id, days, roles) + + +@pytest.fixture() +def sticker_id() -> int: + return random_snowflake() + + +async def test_get_sticker(client, sticker_id): + with client.makes_request( + Route("GET", "/stickers/{sticker_id}", sticker_id=sticker_id) + ): + await client.http.get_sticker(sticker_id) + + +async def test_list_premium_sticker_packs(client): + with client.makes_request(Route("GET", "/sticker-packs")): + await client.http.list_premium_sticker_packs() + + +async def test_get_all_guild_stickers(client, guild_id): + with client.makes_request( + Route("GET", "/guilds/{guild_id}/stickers", guild_id=guild_id) + ): + await client.http.get_all_guild_stickers(guild_id) + + +async def test_get_guild_sticker(client, guild_id, sticker_id): + with client.makes_request( + Route( + "GET", + "/guilds/{guild_id}/stickers/{sticker_id}", + guild_id=guild_id, + sticker_id=sticker_id, + ) + ): + await client.http.get_guild_sticker(guild_id, sticker_id) + + +@pytest.fixture( + params=( + "png", + "json", # Lottie + "txt", # octet-stream + ) +) +def file_type(request) -> Literal["png", "json", "txt"]: + return request.param + + +@pytest.fixture() +def sticker_file(file_type) -> File: + return File(Path(__file__).parent.parent / "assets" / ("test." + file_type)) + + +@pytest.fixture() +def tags() -> str: + return random_string() + + +async def test_create_guild_sticker( + client, guild_id, name, description, tags, sticker_file, file_type, reason +): + if file_type == "json": + content_type = "application/json" + elif file_type == "txt": + content_type = "application/octet-stream" + else: + content_type = f"image/{file_type}" + + form = [ + { + "name": "file", + "value": sticker_file.fp, + "filename": sticker_file.filename, + "content_type": content_type, + } + ] + + payload = {"name": name, "tags": tags, "description": description} + + for k, v in payload.items(): + form.append( + { + "name": k, + "value": v, + } + ) + + with client.makes_request( + Route("POST", "/guilds/{guild_id}/stickers", guild_id=guild_id), + form=form, + reason=reason, + files=[sticker_file], + ): + await client.http.create_guild_sticker( + guild_id, + payload, + sticker_file, + reason, + ) + + +async def test_modify_guild_sticker( + client, guild_id, sticker_id, name, description, tags, reason +): + payload = {"name": name, "tags": tags, "description": description} + with client.makes_request( + Route( + "PATCH", + "/guilds/{guild_id}/stickers/{sticker_id}", + guild_id=guild_id, + sticker_id=sticker_id, + ), + json=payload, + reason=reason, + ): + await client.http.modify_guild_sticker( + guild_id, + sticker_id, + payload, + reason, + ) + + +async def test_delete_guild_sticker(client, guild_id, sticker_id, reason): + with client.makes_request( + Route( + "DELETE", + "/guilds/{guild_id}/stickers/{sticker_id}", + guild_id=guild_id, + sticker_id=sticker_id, + ), + reason=reason, + ): + await client.http.delete_guild_sticker( + guild_id, + sticker_id, + reason, + ) + + +async def test_get_all_custom_emojis(client, guild_id): + with client.makes_request( + Route("GET", "/guilds/{guild_id}/emojis", guild_id=guild_id) + ): + await client.http.get_all_custom_emojis(guild_id) + + +@pytest.fixture() +async def emoji_id() -> int: + return random_snowflake() + + +async def test_get_custom_emoji(client, guild_id, emoji_id): + with client.makes_request( + Route( + "GET", + "/guilds/{guild_id}/emojis/{emoji_id}", + guild_id=guild_id, + emoji_id=emoji_id, + ) + ): + await client.http.get_custom_emoji(guild_id, emoji_id) + + +@pytest.mark.parametrize("roles", (random_amount(random_snowflake), None)) +@pytest.mark.parametrize("image", [random_bytes()]) +async def test_create_custom_emoji(client, guild_id, name, image, roles, reason): + payload = { + "name": name, + "image": image, + "roles": roles or [], + } + + with client.makes_request( + Route("POST", "/guilds/{guild_id}/emojis", guild_id=guild_id), + json=payload, + reason=reason, + ): + await client.http.create_custom_emoji( + guild_id, + name, + image, + roles=roles, + reason=reason, + ) + + +async def test_delete_custom_emoji(client, guild_id, emoji_id, reason): + with client.makes_request( + Route( + "DELETE", + "/guilds/{guild_id}/emojis/{emoji_id}", + guild_id=guild_id, + emoji_id=emoji_id, + ), + reason=reason, + ): + await client.http.delete_custom_emoji( + guild_id, + emoji_id, + reason=reason, + ) + + +@pytest.mark.parametrize("roles", (random_amount(random_snowflake), None)) +async def test_edit_custom_emoji(client, guild_id, emoji_id, name, roles, reason): + payload = { + "name": name, + "roles": roles, + } + + with client.makes_request( + Route( + "PATCH", + "/guilds/{guild_id}/emojis/{emoji_id}", + guild_id=guild_id, + emoji_id=emoji_id, + ), + json=payload, + reason=reason, + ): + await client.http.edit_custom_emoji( + guild_id, + emoji_id, + payload=payload, + reason=reason, + ) diff --git a/tests/http/test_main.py b/tests/http/test_main.py new file mode 100644 index 0000000000..4e3a8544a5 --- /dev/null +++ b/tests/http/test_main.py @@ -0,0 +1,205 @@ +""" +The MIT License (MIT) + +Copyright (c) 2021-present Pycord Development + +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 random + +import pytest + +from discord import Route +from discord.ext.testing import get_mock_response +from discord.ext.testing.fixtures import ( + after, + around, + before, + channel_id, + limit, + message_id, + reason, + user_id, +) + +from ..core import client + + +def test_route_eq(): + """Test route equality.""" + route1 = Route("GET", "/channels/{channel_id}", channel_id=123) + assert route1 != 123 + + +async def test_logout(client): + """Test logging out.""" + with client.makes_request( + Route("POST", "/auth/logout"), + ): + await client.http.logout() + + +@pytest.mark.parametrize( + "recipients", + [random.randrange(2**64) for _ in range(10)], +) +async def test_start_group(client, user_id, recipients): + """Test starting group.""" + payload = { + "recipients": recipients, + } + with client.makes_request( + Route("POST", "/users/{user_id}/channels", user_id=user_id), + json=payload, + ): + await client.http.start_group(user_id, recipients) + + +async def test_leave_group(client, channel_id): + """Test leaving group.""" + with client.makes_request( + Route("DELETE", "/channels/{channel_id}", channel_id=channel_id), + ): + await client.http.leave_group(channel_id) + + +async def test_start_private_message(client, user_id): + """Test starting private message.""" + payload = { + "recipient_id": user_id, + } + with client.makes_request( + Route("POST", "/users/@me/channels"), + json=payload, + ): + await client.http.start_private_message(user_id) + + +async def test_get_message(client, channel_id, message_id): + """Test getting message.""" + with client.makes_request( + Route( + "GET", + "/channels/{channel_id}/messages/{message_id}", + channel_id=channel_id, + message_id=message_id, + ), + ): + await client.http.get_message(channel_id, message_id) + + +async def test_get_channel(client, channel_id): + """Test getting channel.""" + with client.makes_request( + Route("GET", "/channels/{channel_id}", channel_id=channel_id), + ): + await client.http.get_channel(channel_id) + + +async def test_logs_from(client, channel_id, limit, after, before, around): + """Test getting logs from.""" + params = { + "limit": limit, + } + if after: + params["after"] = after + if before: + params["before"] = before + if around: + params["around"] = around + with client.makes_request( + Route( + "GET", + "/channels/{channel_id}/messages", + channel_id=channel_id, + ), + params=params, + ): + await client.http.logs_from(channel_id, limit, before, after, around) + + +async def test_publish_message(client, channel_id, message_id): + """Test publishing message.""" + with client.makes_request( + Route( + "POST", + "/channels/{channel_id}/messages/{message_id}/crosspost", + channel_id=channel_id, + message_id=message_id, + ), + ): + await client.http.publish_message(channel_id, message_id) + + +async def test_pin_message(client, channel_id, message_id, reason): + """Test pinning message.""" + with client.makes_request( + Route( + "PUT", + "/channels/{channel_id}/pins/{message_id}", + channel_id=channel_id, + message_id=message_id, + ), + reason=reason, + ): + await client.http.pin_message(channel_id, message_id, reason) + + +async def test_unpin_message(client, channel_id, message_id, reason): + """Test unpinning message.""" + with client.makes_request( + Route( + "DELETE", + "/channels/{channel_id}/pins/{message_id}", + channel_id=channel_id, + message_id=message_id, + ), + reason=reason, + ): + await client.http.unpin_message(channel_id, message_id, reason) + + +async def test_pins_from(client, channel_id): + """Test getting pins from a channel.""" + with client.makes_request( + Route( + "GET", + "/channels/{channel_id}/pins", + channel_id=channel_id, + ), + ): + await client.http.pins_from(channel_id) + + +# async def test_static_login(client): +# """Test logging in with a static token.""" +# await client.http.close() # Test closing the client before it exists +# with client.makes_request( +# Route("GET", "/users/@me"), +# ): +# await client.http.static_login("token") +# assert client.http.token == "token" +# +# +# @pytest.mark.order(after="test_static_login") +# async def test_close(client): +# """Test closing the client.""" +# await client.close() diff --git a/tests/http/test_member.py b/tests/http/test_member.py new file mode 100644 index 0000000000..d4bacc9e61 --- /dev/null +++ b/tests/http/test_member.py @@ -0,0 +1,227 @@ +""" +The MIT License (MIT) + +Copyright (c) 2021-present Pycord Development + +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 random +from typing import Any + +import pytest + +from discord import Route +from discord.ext.testing.fixtures import guild_id, reason, user_id +from discord.ext.testing.helpers import random_dict + +from ..core import client + + +async def test_kick(client, guild_id, user_id, reason): + """Test kicking a member.""" + with client.makes_request( + Route( + "DELETE", + "/guilds/{guild_id}/members/{user_id}", + guild_id=guild_id, + user_id=user_id, + ), + reason=reason, + ): + await client.http.kick(user_id, guild_id, reason=reason) + + +@pytest.mark.parametrize( + "delete_message_days", + (None, random.randint(0, 7)), +) +@pytest.mark.parametrize("delete_message_seconds", (None, random.randint(0, 604800))) +async def test_ban( + client, + guild_id, + user_id, + delete_message_seconds, + delete_message_days, + reason, +): + """Test banning a member.""" + route = Route( + "PUT", + "/guilds/{guild_id}/bans/{user_id}", + guild_id=guild_id, + user_id=user_id, + ) + args = (user_id, guild_id, delete_message_seconds, delete_message_days, reason) + params = {} + if delete_message_seconds: + params = {"delete_message_seconds": delete_message_seconds} + if delete_message_days and not delete_message_seconds: + params = params or {"delete_message_days": delete_message_days} + with client.makes_request( + route, + params=params, + reason=reason, + ): + with pytest.warns(DeprecationWarning): + await client.http.ban(*args) + else: + with client.makes_request( + route, + params=params, + reason=reason, + ): + await client.http.ban(*args) + + +async def test_unban(client, guild_id, user_id, reason): + """Test unbanning a member.""" + with client.makes_request( + Route( + "DELETE", + "/guilds/{guild_id}/bans/{user_id}", + guild_id=guild_id, + user_id=user_id, + ), + reason=reason, + ): + await client.http.unban(user_id, guild_id, reason=reason) + + +@pytest.mark.parametrize( + "mute", + (None, True, False), +) +@pytest.mark.parametrize( + "deafen", + (None, True, False), +) +async def test_guild_voice_state(client, user_id, guild_id, mute, deafen, reason): + """Test modifying a member's voice state.""" + payload = {} + if mute is not None: + payload["mute"] = mute + if deafen is not None: + payload["deaf"] = deafen + with client.makes_request( + Route( + "PATCH", + "/guilds/{guild_id}/members/{user_id}", + guild_id=guild_id, + user_id=user_id, + ), + json=payload, + reason=reason, + ): + await client.http.guild_voice_state( + user_id, guild_id, mute=mute, deafen=deafen, reason=reason + ) + + +@pytest.mark.parametrize("payload", [random_dict()]) +async def test_edit_profile(client, guild_id, payload): + """Test editing the current user profile.""" + with client.makes_request(Route("PATCH", "/users/@me"), json=payload): + await client.http.edit_profile(payload) + + +@pytest.mark.parametrize( + "nickname", + ("test",), # TODO: Randomize nickname param +) +async def test_change_my_nickname( + client, guild_id: int, nickname: str, reason: str | None +): + with client.makes_request( + Route( + "PATCH", + "/guilds/{guild_id}/members/@me", + guild_id=guild_id, + ), + json={"nick": nickname}, + reason=reason, + ): + await client.http.change_my_nickname(guild_id, nickname, reason=reason) + + +@pytest.mark.parametrize( + "nickname", + ("test",), # TODO: Randomize nickname param +) +async def test_change_nickname( + client, guild_id: int, user_id: int, nickname: str, reason: str | None +): + with client.makes_request( + Route( + "PATCH", + "/guilds/{guild_id}/members/{user_id}", + guild_id=guild_id, + user_id=user_id, + ), + json={"nick": nickname}, + reason=reason, + ): + await client.http.change_nickname(guild_id, user_id, nickname, reason=reason) + + +@pytest.mark.parametrize("payload", [random_dict()]) +async def test_edit_my_voice_state(client, guild_id: int, payload: dict[str, Any]): + with client.makes_request( + Route( + "PATCH", + "/guilds/{guild_id}/voice-states/@me", + guild_id=guild_id, + ), + json=payload, + ): + await client.http.edit_my_voice_state(guild_id, payload) + + +@pytest.mark.parametrize("payload", [random_dict()]) +async def test_edit_voice_state( + client, guild_id: int, user_id: int, payload: dict[str, Any] +): + with client.makes_request( + Route( + "PATCH", + "/guilds/{guild_id}/voice-states/{user_id}", + guild_id=guild_id, + user_id=user_id, + ), + json=payload, + ): + await client.http.edit_voice_state(guild_id, user_id, payload) + + +@pytest.mark.parametrize("payload", [random_dict()]) +async def test_edit_member( + client, guild_id: int, user_id: int, payload: dict[str, Any], reason: str | None +): + with client.makes_request( + Route( + "PATCH", + "/guilds/{guild_id}/members/{user_id}", + guild_id=guild_id, + user_id=user_id, + ), + json=payload, + reason=reason, + ): + await client.http.edit_member(guild_id, user_id, **payload, reason=reason) diff --git a/tests/http/test_reaction.py b/tests/http/test_reaction.py new file mode 100644 index 0000000000..a8b0d2f43d --- /dev/null +++ b/tests/http/test_reaction.py @@ -0,0 +1,126 @@ +""" +The MIT License (MIT) + +Copyright (c) 2021-present Pycord Development + +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 + +from discord import Route +from discord.ext.testing.fixtures import ( + after, + channel_id, + emoji, + limit, + message_id, + user_id, +) + +from ..core import client + + +async def test_add_reaction(client, channel_id, message_id, emoji): + """Test adding reaction.""" + with client.makes_request( + Route( + "PUT", + "/channels/{channel_id}/messages/{message_id}/reactions/{emoji}/@me", + channel_id=channel_id, + message_id=message_id, + emoji=emoji, + ), + ): + await client.http.add_reaction(channel_id, message_id, emoji) + + +async def test_remove_reaction(client, channel_id, message_id, user_id, emoji): + """Test removing reaction.""" + with client.makes_request( + Route( + "DELETE", + "/channels/{channel_id}/messages/{message_id}/reactions/{emoji}/{member_id}", + channel_id=channel_id, + message_id=message_id, + emoji=emoji, + member_id=user_id, + ), + ): + await client.http.remove_reaction(channel_id, message_id, emoji, user_id) + + +async def test_remove_own_reaction(client, channel_id, message_id, emoji): + """Test removing own reaction.""" + with client.makes_request( + Route( + "DELETE", + "/channels/{channel_id}/messages/{message_id}/reactions/{emoji}/@me", + channel_id=channel_id, + message_id=message_id, + emoji=emoji, + ), + ): + await client.http.remove_own_reaction(channel_id, message_id, emoji) + + +async def test_get_reaction_users(client, channel_id, message_id, limit, after, emoji): + """Test getting reaction users.""" + params = {"limit": limit} + if after is not None: + params["after"] = after + with client.makes_request( + Route( + "GET", + "/channels/{channel_id}/messages/{message_id}/reactions/{emoji}", + channel_id=channel_id, + message_id=message_id, + emoji=emoji, + ), + params=params, + ): + await client.http.get_reaction_users( + channel_id, message_id, emoji, limit, after + ) + + +async def test_clear_reactions(client, channel_id, message_id): + """Test clearing reactions.""" + with client.makes_request( + Route( + "DELETE", + "/channels/{channel_id}/messages/{message_id}/reactions", + channel_id=channel_id, + message_id=message_id, + ), + ): + await client.http.clear_reactions(channel_id, message_id) + + +async def test_clear_single_reaction(client, channel_id, message_id, emoji): + """Test clearing single reaction.""" + with client.makes_request( + Route( + "DELETE", + "/channels/{channel_id}/messages/{message_id}/reactions/{emoji}", + channel_id=channel_id, + message_id=message_id, + emoji=emoji, + ), + ): + await client.http.clear_single_reaction(channel_id, message_id, emoji) diff --git a/tests/http/test_send.py b/tests/http/test_send.py new file mode 100644 index 0000000000..ba60de1f8c --- /dev/null +++ b/tests/http/test_send.py @@ -0,0 +1,367 @@ +""" +The MIT License (MIT) + +Copyright (c) 2021-present Pycord Development + +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 + +from typing import TYPE_CHECKING + +import pytest + +from discord import MessageFlags, Route, utils +from discord.ext.testing.fixtures import ( + allowed_mentions, + channel_id, + components, + content, +) +from discord.ext.testing.fixtures import embed as embed_ +from discord.ext.testing.fixtures import ( + embeds, + message_id, + message_ids, + nonce, + reason, + stickers, + user_id, +) +from discord.ext.testing.helpers import ( + powerset, + random_amount, + random_dict, + random_file, + random_message_reference, +) +from discord.types import message + +from ..core import client + +if TYPE_CHECKING: + from discord.file import File + + +@pytest.fixture(params=(True, False)) +def tts(request) -> bool: + return request.param + + +@pytest.fixture(params=(None, random_message_reference())) +def message_reference(request) -> message.MessageReference | None: + return request.param + + +@pytest.fixture( + params=(MessageFlags(suppress_embeds=True).value,) +) # this should maybe be optional +def flags(request) -> int | None: + return request.param + + +@pytest.fixture +def files() -> list[File]: + return random_amount(random_file) + + +def attachment_helper(payload, **kwargs): + form = [] + attachments = [] + form.append({"name": "payload_json"}) + for index, file in enumerate(kwargs["files"]): + attachments.append( + {"id": index, "filename": file.filename, "description": file.description} + ) + form.append( + { + "name": f"files[{index}]", + "value": file.fp, + "filename": file.filename, + "content_type": "application/octet-stream", + } + ) + if "attachments" not in payload: + payload["attachments"] = attachments + else: + payload["attachments"].extend(attachments) + form[0]["value"] = utils._to_json(payload) + return { + "form": form, + "files": kwargs["files"], + } + + +def payload_helper(**kwargs): + payload = {} + if kwargs.get("tts") or kwargs.get("files"): + payload["tts"] = kwargs.get("tts", False) + if kwargs.get("content"): + payload["content"] = kwargs["content"] + if kwargs.get("embed"): + payload["embeds"] = [kwargs["embed"]] + if kwargs.get("embeds"): + payload["embeds"] = kwargs["embeds"] + if kwargs.get("nonce"): + payload["nonce"] = kwargs["nonce"] + if kwargs.get("allowed_mentions"): + payload["allowed_mentions"] = kwargs["allowed_mentions"] + if kwargs.get("message_reference"): + payload["message_reference"] = kwargs["message_reference"] + if kwargs.get("stickers"): + payload["sticker_ids"] = kwargs["stickers"] + if kwargs.get("components"): + payload["components"] = kwargs["components"] + if kwargs.get("flags"): + payload["flags"] = kwargs["flags"] + + if kwargs.get("files"): + return attachment_helper(payload, **kwargs) + return { + "json": payload, + } + + +def edit_file_payload_helper(**kwargs): + payload = {} + if "attachments" in kwargs: + payload["attachments"] = kwargs["attachments"] + if "flags" in kwargs: + payload["flags"] = kwargs["flags"] + if "content" in kwargs: + payload["content"] = kwargs["content"] + if "embeds" in kwargs: + payload["embeds"] = kwargs["embeds"] + if "allowed_mentions" in kwargs: + payload["allowed_mentions"] = kwargs["allowed_mentions"] + if "components" in kwargs: + payload["components"] = kwargs["components"] + if "files" in kwargs: + return attachment_helper(payload, **kwargs) + return { + "json": payload, + } + + +async def test_send_message( + client, + channel_id, + content, + tts, + embed, + embeds, + nonce, + allowed_mentions, + message_reference, + stickers, + components, + flags, +): + """Test sending a message.""" + + with client.makes_request( + Route("POST", "/channels/{channel_id}/messages", channel_id=channel_id), + **payload_helper( + content=content, + tts=tts, + embed=embed, + embeds=embeds, + nonce=nonce, + allowed_mentions=allowed_mentions, + message_reference=message_reference, + stickers=stickers, + components=components, + flags=flags, + ), + ): + await client.http.send_message( + channel_id, + content, + tts=tts, + embed=embed, + embeds=embeds, + nonce=nonce, + allowed_mentions=allowed_mentions, + message_reference=message_reference, + stickers=stickers, + components=components, + flags=flags, + ) + + +async def test_send_typing(client, channel_id): + """Test sending typing.""" + with client.makes_request( + Route("POST", "/channels/{channel_id}/typing", channel_id=channel_id), + ): + await client.http.send_typing(channel_id) + + +async def test_send_files( + client, + channel_id, + content, + tts, + embed, + embeds, + nonce, + allowed_mentions, + message_reference, + stickers, + components, + flags, + files, +): + """Test sending files.""" + with client.makes_request( + Route("POST", "/channels/{channel_id}/messages", channel_id=channel_id), + **payload_helper( + content=content, + tts=tts, + embed=embed, + embeds=embeds, + nonce=nonce, + allowed_mentions=allowed_mentions, + message_reference=message_reference, + stickers=stickers, + components=components, + flags=flags, + files=files, + ), + ): + await client.http.send_files( + channel_id, + files=files, + content=content, + tts=tts, + embed=embed, + embeds=embeds, + nonce=nonce, + allowed_mentions=allowed_mentions, + message_reference=message_reference, + stickers=stickers, + components=components, + flags=flags, + ) + + +@pytest.mark.parametrize( + "exclude", + powerset( + [ + "content", + "embeds", + "allowed_mentions", + "components", + "flags", + ] + ), +) +async def test_edit_files( # TODO: Add attachments + client, + channel_id, + message_id, + content, + # embed, # TODO: Evaluate: Should edit_files support embed shortcut kwarg? + embeds, + allowed_mentions, + components, + flags, + files, + exclude, +): + """Test editing files.""" + kwargs = { + "content": content, + # "embed": embed, + "embeds": embeds, + "allowed_mentions": allowed_mentions, + "components": components, + "flags": flags, + } + for key in exclude: + del kwargs[key] + with client.makes_request( + Route( + "PATCH", + f"/channels/{channel_id}/messages/{message_id}", + channel_id=channel_id, + message_id=message_id, + ), + **edit_file_payload_helper(files=files, **kwargs), + ): + await client.http.edit_files(channel_id, message_id, files=files, **kwargs) + + +async def test_delete_message( + client, + channel_id, + message_id, + reason, +): + """Test deleting a message.""" + with client.makes_request( + Route( + "DELETE", + "/channels/{channel_id}/messages/{message_id}", + channel_id=channel_id, + message_id=message_id, + ), + reason=reason, + ): + await client.http.delete_message(channel_id, message_id, reason=reason) + + +async def test_delete_messages( + client, + channel_id, + message_ids, + reason, +): + """Test deleting multiple messages.""" + with client.makes_request( + Route( + "POST", + "/channels/{channel_id}/messages/bulk-delete", + channel_id=channel_id, + ), + json={"messages": message_ids}, + reason=reason, + ): + await client.http.delete_messages(channel_id, message_ids, reason=reason) + + +@pytest.mark.parametrize("fields", [random_dict()]) +async def test_edit_message( + client, + channel_id, + message_id, + fields, +): + """Test editing a message.""" + with client.makes_request( + Route( + "PATCH", + "/channels/{channel_id}/messages/{message_id}", + channel_id=channel_id, + message_id=message_id, + ), + json=fields, + ): + await client.http.edit_message(channel_id, message_id, **fields) diff --git a/tests/http/test_thread.py b/tests/http/test_thread.py new file mode 100644 index 0000000000..b0e2db50ce --- /dev/null +++ b/tests/http/test_thread.py @@ -0,0 +1,293 @@ +""" +The MIT License (MIT) + +Copyright (c) 2021-present Pycord Development + +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 random +from typing import get_args + +import pytest + +from discord import Route +from discord.ext.testing.fixtures import ( + allowed_mentions, + applied_tags, + before, + channel_id, + components, + content, +) +from discord.ext.testing.fixtures import embed as embed_ +from discord.ext.testing.fixtures import ( + embeds, + guild_id, + invitable, + limit, + message_id, + name, + nonce, + reason, + stickers, + user_id, +) +from discord.ext.testing.helpers import random_archive_duration +from discord.types import channel, threads + +from ..core import client + + +@pytest.fixture +def auto_archive_duration() -> threads.ThreadArchiveDuration: + return random_archive_duration() + + +@pytest.fixture(params=(random.choice(get_args(channel.ChannelType)),)) +def type_(request) -> channel.ChannelType: + return request.param + + +@pytest.fixture(params=(random.randint(0, 21600), None)) +def rate_limit_per_user(request) -> int | None: + return request.param + + +async def test_start_thread_with_message( + client, channel_id, message_id, name, auto_archive_duration, reason +): + payload = { + "name": name, + "auto_archive_duration": auto_archive_duration, + } + with client.makes_request( + Route( + "POST", + "/channels/{channel_id}/messages/{message_id}/threads", + channel_id=channel_id, + message_id=message_id, + ), + json=payload, + reason=reason, + ): + await client.http.start_thread_with_message( + channel_id, + message_id, + name=name, + auto_archive_duration=auto_archive_duration, + reason=reason, + ) + + +async def test_start_thread_without_message( + client, channel_id, name, auto_archive_duration, type_, invitable, reason +): + payload = { + "name": name, + "auto_archive_duration": auto_archive_duration, + "type": type_, + "invitable": invitable, + } + with client.makes_request( + Route( + "POST", + "/channels/{channel_id}/threads", + channel_id=channel_id, + ), + json=payload, + reason=reason, + ): + await client.http.start_thread_without_message( + channel_id, + name=name, + auto_archive_duration=auto_archive_duration, + type=type_, + invitable=invitable, + reason=reason, + ) + + +async def test_start_forum_thread( + client, + channel_id, + content, + name, + auto_archive_duration, + rate_limit_per_user, + invitable, + applied_tags, + reason, + embed, + embeds, + nonce, + allowed_mentions, + stickers, + components, +): + payload = { + "name": name, + "auto_archive_duration": auto_archive_duration, + "invitable": invitable, + } + if content: + payload["content"] = content + if applied_tags: + payload["applied_tags"] = applied_tags + if embed: + payload["embeds"] = [embed] + if embeds: + payload["embeds"] = embeds + if nonce: + payload["nonce"] = nonce + if allowed_mentions: + payload["allowed_mentions"] = allowed_mentions + if components: + payload["components"] = components + if stickers: + payload["sticker_ids"] = stickers + if rate_limit_per_user: + payload["rate_limit_per_user"] = rate_limit_per_user + with client.makes_request( + Route( + "POST", + "/channels/{channel_id}/threads?has_message=true", + channel_id=channel_id, + ), + json=payload, + reason=reason, + ): + await client.http.start_forum_thread( + channel_id, + content=content, + name=name, + auto_archive_duration=auto_archive_duration, + rate_limit_per_user=rate_limit_per_user, + invitable=invitable, + applied_tags=applied_tags, + reason=reason, + embed=embed, + embeds=embeds, + nonce=nonce, + allowed_mentions=allowed_mentions, + stickers=stickers, + components=components, + ) + + +async def test_join_thread(client, channel_id): + with client.makes_request( + Route( + "PUT", "/channels/{channel_id}/thread-members/@me", channel_id=channel_id + ), + ): + await client.http.join_thread(channel_id) + + +async def test_add_user_to_thread(client, channel_id, user_id): + with client.makes_request( + Route( + "PUT", + "/channels/{channel_id}/thread-members/{user_id}", + channel_id=channel_id, + user_id=user_id, + ), + ): + await client.http.add_user_to_thread(channel_id, user_id) + + +async def test_leave_thread(client, channel_id): + with client.makes_request( + Route( + "DELETE", "/channels/{channel_id}/thread-members/@me", channel_id=channel_id + ), + ): + await client.http.leave_thread(channel_id) + + +async def test_remove_user_from_thread(client, channel_id, user_id): + with client.makes_request( + Route( + "DELETE", + "/channels/{channel_id}/thread-members/{user_id}", + channel_id=channel_id, + user_id=user_id, + ), + ): + await client.http.remove_user_from_thread(channel_id, user_id) + + +async def test_get_public_archived_threads(client, channel_id, before, limit): + params = {"limit": limit} + if before: + params["before"] = before + with client.makes_request( + Route( + "GET", + "/channels/{channel_id}/threads/archived/public", + channel_id=channel_id, + ), + params=params, + ): + await client.http.get_public_archived_threads(channel_id, before, limit) + + +async def test_get_private_archived_threads(client, channel_id, before, limit): + params = {"limit": limit} + if before: + params["before"] = before + with client.makes_request( + Route( + "GET", + "/channels/{channel_id}/threads/archived/private", + channel_id=channel_id, + ), + params=params, + ): + await client.http.get_private_archived_threads(channel_id, before, limit) + + +async def test_get_joined_private_archived_threads(client, channel_id, before, limit): + params = {"limit": limit} + if before: + params["before"] = before + with client.makes_request( + Route( + "GET", + "/channels/{channel_id}/users/@me/threads/archived/private", + channel_id=channel_id, + ), + params=params, + ): + await client.http.get_joined_private_archived_threads(channel_id, before, limit) + + +async def test_get_active_threads(client, guild_id): + with client.makes_request( + Route("GET", "/guilds/{guild_id}/threads/active", guild_id=guild_id), + ): + await client.http.get_active_threads(guild_id) + + +async def test_get_thread_members(client, channel_id): + with client.makes_request( + Route("GET", "/channels/{channel_id}/thread-members", channel_id=channel_id), + ): + await client.http.get_thread_members(channel_id) diff --git a/tests/http/test_webhook.py b/tests/http/test_webhook.py new file mode 100644 index 0000000000..1becd7a920 --- /dev/null +++ b/tests/http/test_webhook.py @@ -0,0 +1,85 @@ +""" +The MIT License (MIT) + +Copyright (c) 2021-present Pycord Development + +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 pytest + +from discord import Route +from discord.ext.testing.fixtures import avatar, channel_id, guild_id, name, reason +from discord.ext.testing.helpers import random_snowflake + +from ..core import client + + +@pytest.fixture +def webhook_id() -> int: + return random_snowflake() + + +@pytest.fixture +def webhook_channel_id() -> int: + return random_snowflake() + + +async def test_create_webhook(client, channel_id, name, avatar, reason): + payload = {"name": name} + if avatar is not None: + payload["avatar"] = avatar + with client.makes_request( + Route("POST", "/channels/{channel_id}/webhooks", channel_id=channel_id), + json=payload, + reason=reason, + ): + await client.http.create_webhook( + channel_id, name=name, avatar=avatar, reason=reason + ) + + +async def test_channel_webhooks(client, channel_id): + with client.makes_request( + Route("GET", "/channels/{channel_id}/webhooks", channel_id=channel_id), + ): + await client.http.channel_webhooks(channel_id) + + +async def test_guild_webhooks(client, guild_id): + with client.makes_request( + Route("GET", "/guilds/{guild_id}/webhooks", guild_id=guild_id), + ): + await client.http.guild_webhooks(guild_id) + + +async def test_get_webhook(client, webhook_id): + with client.makes_request( + Route("GET", "/webhooks/{webhook_id}", webhook_id=webhook_id) + ): + await client.http.get_webhook(webhook_id) + + +async def test_follow_webhook(client, channel_id, webhook_channel_id, reason): + payload = {"webhook_channel_id": str(webhook_channel_id)} + with client.makes_request( + Route("POST", "/channels/{channel_id}/followers", channel_id=channel_id), + json=payload, + reason=reason, + ): + await client.http.follow_webhook(channel_id, webhook_channel_id, reason) diff --git a/tests/test_import.py b/tests/test_import.py new file mode 100644 index 0000000000..abe63865e6 --- /dev/null +++ b/tests/test_import.py @@ -0,0 +1,48 @@ +""" +The MIT License (MIT) + +Copyright (c) 2021-present Pycord Development + +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 + +# A test which imports everything. Mostly to help reduce issues with code that has +# invalid syntax on specific versions of Python, i.e. if the new annotations are used +# without the __future__ import. + + +def test_import(): + import discord + + +def test_import_ext_bridge(): + import discord.ext.bridge + + +def test_import_ext_commands(): + import discord.ext.commands + + +def test_import_ext_pages(): + import discord.ext.pages + + +def test_import_ext_tasks(): + import discord.ext.tasks diff --git a/tests/test_main.py b/tests/test_main.py new file mode 100644 index 0000000000..108f5bf034 --- /dev/null +++ b/tests/test_main.py @@ -0,0 +1,133 @@ +""" +The MIT License (MIT) + +Copyright (c) 2021-present Pycord Development + +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 pytest + +from discord import Route +from discord.ext.testing import get_mock_response + +from .core import client + + +@pytest.mark.parametrize( + "with_counts", + (True, False), +) +async def test_fetch_guild(client, with_counts): + data = get_mock_response("get_guild") + with client.patch("get_guild"): + guild = await client.fetch_guild(881207955029110855, with_counts=with_counts) + guild_dict = dict( + id=str(guild.id), + name=guild.name, + icon=guild.icon.key if guild.icon else None, + description=guild.description, + splash=guild.splash.key if guild.splash else None, + discovery_splash=guild.discovery_splash.key if guild.discovery_splash else None, + features=guild.features, + approximate_member_count=guild.approximate_member_count, + approximate_presence_count=guild.approximate_presence_count, + emojis=[ + dict( + name=emoji.name, + roles=emoji.roles, + id=str(emoji.id), + require_colons=emoji.require_colons, + managed=emoji.managed, + animated=emoji.animated, + available=emoji.available, + ) + for emoji in guild.emojis + ], + stickers=[ + dict( + id=str(sticker.id), + name=sticker.name, + tags=sticker.emoji, + type=sticker.type.value, + format_type=sticker.format.value, + description=sticker.description, + asset="", # Deprecated + available=sticker.available, + guild_id=str(sticker.guild_id), + ) + for sticker in guild.stickers + ], + banner=guild.banner.key if guild.banner else None, + owner_id=str(guild.owner_id), + application_id=data["application_id"], # TODO: Fix + region=data["region"], # Deprecated + afk_channel_id=str(guild.afk_channel.id) if guild.afk_channel else None, + afk_timeout=guild.afk_timeout, + system_channel_id=str(guild._system_channel_id) + if guild._system_channel_id + else None, + widget_enabled=data["widget_enabled"], # TODO: Fix + widget_channel_id=data["widget_channel_id"], # TODO: Fix + verification_level=guild.verification_level.value, + roles=[ + dict( + id=str(role.id), + name=role.name, + permissions=str(role.permissions.value), + position=role.position, + color=role.color.value, + hoist=role.hoist, + managed=role.managed, + mentionable=role.mentionable, + icon=role.icon.key if role.icon else None, + unicode_emoji=role.unicode_emoji, + flags=list(filter(lambda d: d["id"] == str(role.id), data["roles"]))[0][ + "flags" + ], # TODO: Fix + ) + for role in guild.roles + ], + default_message_notifications=guild.default_notifications.value, + mfa_level=guild.mfa_level, + explicit_content_filter=guild.explicit_content_filter.value, + max_presences=guild.max_presences, + max_members=guild.max_members, + max_stage_video_channel_users=data[ + "max_stage_video_channel_users" + ], # TODO: Fix + max_video_channel_users=guild.max_video_channel_users, + vanity_url_code=data["vanity_url_code"], # TODO: Fix + premium_tier=guild.premium_tier, + premium_subscription_count=guild.premium_subscription_count, + system_channel_flags=guild.system_channel_flags.value, + preferred_locale=guild.preferred_locale, + rules_channel_id=str(guild._rules_channel_id) + if guild._rules_channel_id + else None, + public_updates_channel_id=str(guild._public_updates_channel_id) + if guild._public_updates_channel_id + else None, + hub_type=data["hub_type"], # TODO: Fix + premium_progress_bar_enabled=guild.premium_progress_bar_enabled, + nsfw=data["nsfw"], # TODO: Fix + nsfw_level=guild.nsfw_level.value, + ) + assert guild_dict == data diff --git a/tests/test_utils.py b/tests/test_utils.py deleted file mode 100644 index bf8dc63b99..0000000000 --- a/tests/test_utils.py +++ /dev/null @@ -1,161 +0,0 @@ -""" -The MIT License (MIT) - -Copyright (c) 2015-2021 Rapptz -Copyright (c) 2021-present Pycord Development - -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. -""" -# mypy: implicit-reexport=True -from typing import TypeVar - -import pytest - -from discord.utils import ( - MISSING, - _cached_property, - _parse_ratelimit_header, - _unique, - async_all, - copy_doc, - find, - get, - maybe_coroutine, - snowflake_time, - time_snowflake, - utcnow, -) - -from .helpers import coroutine - -A = TypeVar("A") -B = TypeVar("B") - - -def test_temporary(): - assert True - - -# def test_copy_doc() -> None: -# def foo(a: A, b: B) -> Tuple[A, B]: -# """ -# This is a test function. -# """ -# return a, b -# -# @copy_doc(foo) -# def bar(a, b): # type: ignore[no-untyped-def] -# return a, b -# -# assert bar.__doc__ == foo.__doc__ -# assert signature(bar) == signature(foo) -# -# -# def test_snowflake() -> None: -# now = utcnow().replace(microsecond=0) -# snowflake = time_snowflake(now) -# assert snowflake_time(snowflake) == now -# -# -# def test_missing() -> None: -# assert MISSING != object() -# assert not MISSING -# assert repr(MISSING) == '...' -# -# -# def test_cached_property() -> None: -# class Test: -# def __init__(self, x: int): -# self.x = x -# -# @_cached_property -# def foo(self) -> int: -# self.x += 1 -# return self.x -# -# t = Test(0) -# assert isinstance(_cached_property.__get__(_cached_property(None), None, None), _cached_property) -# assert t.foo == 1 -# assert t.foo == 1 -# -# -# def test_find_get() -> None: -# class Obj: -# def __init__(self, value: int): -# self.value = value -# self.deep = self -# -# def __eq__(self, other: Any) -> bool: -# return isinstance(other, self.__class__) and self.value == other.value -# -# def __repr__(self) -> str: -# return f'' -# -# obj_list = [Obj(i) for i in range(10)] -# for i in range(11): -# for val in ( -# find(lambda o: o.value == i, obj_list), -# get(obj_list, value=i), -# get(obj_list, deep__value=i), -# get(obj_list, value=i, deep__value=i), -# ): -# if i >= len(obj_list): -# assert val is None -# else: -# assert val == Obj(i) -# -# -# def test_unique() -> None: -# values = [random.randint(0, 100) for _ in range(1000)] -# unique = _unique(values) -# unique.sort() -# assert unique == list(set(values)) -# -# -# @pytest.mark.parametrize('use_clock', (True, False)) -# @pytest.mark.parametrize('value', list(range(0, 100, random.randrange(5, 10)))) -# def test_parse_ratelimit_header(use_clock, value): # type: ignore[no-untyped-def] -# class RatelimitRequest: -# def __init__(self, reset_after: int): -# self.headers = { -# 'X-Ratelimit-Reset-After': reset_after, -# 'X-Ratelimit-Reset': (utcnow() + datetime.timedelta(seconds=reset_after)).timestamp(), -# } -# -# assert round(_parse_ratelimit_header(RatelimitRequest(value), use_clock=use_clock)) == value -# -# -# @pytest.mark.parametrize('value', range(5)) -# async def test_maybe_coroutine(value) -> None: # type: ignore[no-untyped-def] -# assert value == await maybe_coroutine(lambda v: v, value) -# assert value == await maybe_coroutine(coroutine, value) -# -# -# @pytest.mark.parametrize('size', list(range(10, 20))) -# @pytest.mark.filterwarnings("ignore:coroutine 'coroutine' was never awaited") -# async def test_async_all(size) -> None: # type: ignore[no-untyped-def] -# values = [] -# raw_values = [] -# -# for i in range(size): -# value = random.choices((True, False), (size - 1, 1))[0] -# raw_values.append(value) -# values.append(coroutine(value) if random.choice((True, False)) else value) -# -# assert all(raw_values) == await async_all(values)