diff --git a/.dockerignore b/.dockerignore index ca4f10e..ad17ab6 100644 --- a/.dockerignore +++ b/.dockerignore @@ -3,4 +3,5 @@ __pycache__/ deploy/ data/db.sqlite3 -data/django_key.txt \ No newline at end of file +data/django_key.txt +.env \ No newline at end of file diff --git a/.github/workflows/docker-deploy.yml b/.github/workflows/docker-deploy.yml new file mode 100644 index 0000000..5db0622 --- /dev/null +++ b/.github/workflows/docker-deploy.yml @@ -0,0 +1,49 @@ +name: Docker Deploy + +on: + workflow_run: + workflows: ["Run Tests"] + branches: [main] + types: + - completed + workflow_dispatch: + +jobs: + build-and-push: + runs-on: ubuntu-latest + + if: > + github.event.workflow_run.conclusion == 'success' || + github.event_name == 'workflow_dispatch' + + steps: + - name: Check out repository + uses: actions/checkout@v2 + + - name: Log in to Docker Hub + uses: docker/login-action@v1 + with: + username: ${{ secrets.DOCKER_HUB_USERNAME }} + password: ${{ secrets.DOCKER_HUB_PASSWORD }} + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v1 + + - name: Build and push Docker image for x86 + run: | + echo "Building ${app}-image for x86" + docker build -t "${docker_user}/${app}" . + echo "Pushing x86 ${app}-image to dockerhub" + docker push "${docker_user}/${app}" + env: + app: discoflix + docker_user: nickheyer + + - name: Build and push Docker image for ARM64 + run: | + echo "Building ${app}-image for ARM64" + export DOCKER_CLI_EXPERIMENTAL=enabled + docker buildx build --platform=linux/arm64 -t "${docker_user}/${app}_rpi" . --push + env: + app: discoflix + docker_user: nickheyer diff --git a/.github/workflows/feed.yml b/.github/workflows/feed.yml index 8aea9c3..de96a1b 100644 --- a/.github/workflows/feed.yml +++ b/.github/workflows/feed.yml @@ -1,21 +1,13 @@ name: Update Feed ~ Heyer.app -# Controls when the workflow will run -on: - # Triggers the workflow on push or pull request events but only for the "main" branch - push: - branches: [ "main" ] - - # Allows you to run this workflow manually from the Actions tab - workflow_dispatch: +on: push jobs: send-request: runs-on: ubuntu-latest - # Steps represent a sequence of tasks that will be executed as part of the job steps: - name: Send diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..a31b042 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,32 @@ +name: Run Tests + +on: push + +jobs: + test: + runs-on: ubuntu-latest + + steps: + - name: Check out repository + uses: actions/checkout@v2 + + - name: Set up Python + uses: actions/setup-python@v2 + with: + python-version: '3.11' + + - name: Cache Python dependencies + uses: actions/cache@v2 + with: + path: ~/.cache/pip + key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements.txt') }} + restore-keys: | + ${{ runner.os }}-pip- + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + + - name: Run pytest + run: pytest diff --git a/.gitignore b/.gitignore index 6379088..1f690cc 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ __pycache__/ deploy/ data/db.sqlite3 data/django_key.txt +.env diff --git a/DiscoFlix/settings.py b/DiscoFlix/settings.py index 64c0953..2fd7ba8 100644 --- a/DiscoFlix/settings.py +++ b/DiscoFlix/settings.py @@ -91,6 +91,9 @@ def get_or_create_secret_key(): "default": { "ENGINE": "django.db.backends.sqlite3", "NAME": BASE_DIR / "data/db.sqlite3", + "TEST": { + "NAME": BASE_DIR / "data/test_db.sqlite3" + } } } diff --git a/DiscoFlixBot/commands/echo.py b/DiscoFlixBot/commands/echo.py index 9b4d3fa..237433f 100644 --- a/DiscoFlixBot/commands/echo.py +++ b/DiscoFlixBot/commands/echo.py @@ -5,7 +5,7 @@ class EchoCommand(Command): def __init__(self) -> None: super().__init__() self.name = "echo" - self.permissions = ["user", "developer"] + self.permissions = ["developer"] self.description = "Confirm bot can respond to messages" self.aliases = ["echo"] self.requires_input = True diff --git a/DiscoFlixBot/commands/error.py b/DiscoFlixBot/commands/error.py index 31ab0e9..9a87459 100644 --- a/DiscoFlixBot/commands/error.py +++ b/DiscoFlixBot/commands/error.py @@ -5,7 +5,7 @@ class ErrorCommand(Command): def __init__(self) -> None: super().__init__() self.name = "error" - self.permissions = ["user", "developer", "owner"] + self.permissions = ["developer", "owner"] self.description = "Confirm bot is handling errors as intended" self.aliases = ["error"] diff --git a/DiscoFlixBot/commands/log.py b/DiscoFlixBot/commands/log.py index 265956d..6869d16 100644 --- a/DiscoFlixBot/commands/log.py +++ b/DiscoFlixBot/commands/log.py @@ -5,7 +5,7 @@ class LogCommand(Command): def __init__(self) -> None: super().__init__() self.name = "log" - self.permissions = ["user", "developer", "owner"] + self.permissions = ["developer", "owner"] self.description = "Confirm bot is logging information to console/server as intended" self.aliases = ["log"] self.requires_input = True diff --git a/DiscoFlixBot/commands/test.py b/DiscoFlixBot/commands/test.py index b81a5a0..fdd6ba3 100644 --- a/DiscoFlixBot/commands/test.py +++ b/DiscoFlixBot/commands/test.py @@ -5,7 +5,7 @@ class TestCommand(Command): def __init__(self) -> None: super().__init__() self.name = "test" - self.permissions = ["owner", "admin", "user"] + self.permissions = ["owner", "admin"] self.description = "Confirm bot is on and listening" self.aliases = ["test"] diff --git a/DiscoFlixBot/controller.py b/DiscoFlixBot/controller.py index 80c1b1a..7aa60d6 100644 --- a/DiscoFlixBot/controller.py +++ b/DiscoFlixBot/controller.py @@ -1,6 +1,5 @@ -from DiscoFlixClient.utils import get_config, get_config_sync, get_state_sync +from DiscoFlixClient.utils import get_config from DiscoFlixBot.bot import DiscordBot -import asyncio async def main(): diff --git a/DiscoFlixClient/tests.py b/DiscoFlixClient/tests.py deleted file mode 100644 index 7ce503c..0000000 --- a/DiscoFlixClient/tests.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.test import TestCase - -# Create your tests here. diff --git a/DiscoFlixClient/tests/__init__.py b/DiscoFlixClient/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/DiscoFlixClient/tests/commands/__init__.py b/DiscoFlixClient/tests/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/DiscoFlixClient/tests/commands/test_help.py b/DiscoFlixClient/tests/commands/test_help.py new file mode 100644 index 0000000..1a2b69c --- /dev/null +++ b/DiscoFlixClient/tests/commands/test_help.py @@ -0,0 +1,235 @@ +import pytest +import discord.ext.test as dpytest +import logging +from DiscoFlixClient.tests.common import common + +LOGGER = logging.getLogger(__name__) + +# TEST COMMAND +TEST_PREFIX = '!df' +TEST_COMMAND = 'help' +TEST_MESSAGE = f'{TEST_PREFIX} {TEST_COMMAND}' + +@pytest.mark.asyncio +@pytest.mark.django_db(transaction=True) +async def test_help_admin(bot): + # GET DISCORD USER + TEST_ADMIN = common.get_user_by_name('TESTADMIN') + + # USER ISSUES COMMAND + await dpytest.message(content=TEST_MESSAGE, member=TEST_ADMIN) + await dpytest.run_all_events() + + # BOT RESPONDS WITH MESSAGE / EMBED + sent_emb = dpytest.get_embed(peek=True) + + # EVALUATE GENERATED MESSAGE / EMBED + assert sent_emb.title == 'Help' + assert sent_emb.description == 'List of available commands:' + + embed_fields = sent_emb.fields + available_commands = sorted([field.name.split(' ')[0] for field in embed_fields]) + expected_commands = sorted([ + 'add-user', + 'error', + 'delete-user', + 'log', + 'test', + 'show', + 'movie', + 'add-admin', + 'help' + ]) + + assert available_commands == expected_commands + +@pytest.mark.asyncio +@pytest.mark.django_db(transaction=True) +async def test_help_user(bot): + # GET DISCORD USER + TEST_USER = common.get_user_by_name('TESTUSER') + + # USER ISSUES COMMAND + await dpytest.message(content=TEST_MESSAGE, member=TEST_USER) + await dpytest.run_all_events() + + # BOT RESPONDS WITH MESSAGE / EMBED + sent_emb = dpytest.get_embed(peek=True) + + # EVALUATE GENERATED MESSAGE / EMBED + assert sent_emb.title == 'Help' + assert sent_emb.description == 'List of available commands:' + + embed_fields = sent_emb.fields + available_commands = sorted([field.name.split(' ')[0] for field in embed_fields]) + expected_commands = sorted([ + 'help', + 'show', + 'movie' + ]) + + assert available_commands == expected_commands + +@pytest.mark.asyncio +@pytest.mark.django_db(transaction=True) +async def test_help_nonuser(bot): + # GET DISCORD USER + TEST_NONUSER = common.get_user_by_name('TESTNONUSER') + + # USER ISSUES COMMAND + await dpytest.message(content=TEST_MESSAGE, member=TEST_NONUSER) + await dpytest.run_all_events() + + # BOT RESPONDS WITH MESSAGE / EMBED + sent_emb = dpytest.get_embed(peek=True) + + # EVALUATE GENERATED MESSAGE / EMBED + assert sent_emb.title == 'Help' + assert sent_emb.description == 'List of available commands:' + + embed_fields = sent_emb.fields + available_commands = sorted([field.name.split(' ')[0] for field in embed_fields]) + expected_commands = sorted([ + 'help' + ]) + + assert available_commands == expected_commands + +@pytest.mark.asyncio +@pytest.mark.django_db(transaction=True) +async def test_help_admin_with_debug(bot): + # SET DEBUG TO TRUE IN CONFIG + await common.edit_configuration(is_debug=True) + + # GET DISCORD USER + TEST_ADMIN = common.get_user_by_name('TESTADMIN') + + # USER ISSUES COMMAND + await dpytest.message(content=TEST_MESSAGE, member=TEST_ADMIN) + await dpytest.run_all_events() + + # BOT RESPONDS WITH MESSAGE / EMBED + sent_emb = dpytest.get_embed(peek=True) + + # EVALUATE GENERATED MESSAGE / EMBED + assert sent_emb.title == 'Help' + assert sent_emb.description == 'List of available commands:' + + embed_fields = sent_emb.fields + debug_field = embed_fields.pop() + assert debug_field.name == 'User Debug Information' + + [d_user, d_roles, d_commands] = debug_field.value.splitlines() + assert d_user == 'Username: `TESTADMIN#0001`' + assert all(exp in d_roles for exp in ['unrestricted', 'user', 'owner', 'admin']) + assert d_commands == 'Commands Registered: `11`' + + available_commands = sorted([field.name.split(' ')[0] for field in embed_fields]) + expected_commands = sorted([ + 'add-user', + 'echo', + 'reject', + 'error', + 'delete-user', + 'log', + 'test', + 'show', + 'movie', + 'add-admin', + 'help' + ]) + + assert available_commands == expected_commands + +@pytest.mark.asyncio +@pytest.mark.django_db(transaction=True) +async def test_help_user_with_debug(bot): + # SET DEBUG TO TRUE IN CONFIG + await common.edit_configuration(is_debug=True) + + # GET DISCORD USER + TEST_USER = common.get_user_by_name('TESTUSER') + + # USER ISSUES COMMAND + await dpytest.message(content=TEST_MESSAGE, member=TEST_USER) + await dpytest.run_all_events() + + # BOT RESPONDS WITH MESSAGE / EMBED + sent_emb = dpytest.get_embed(peek=True) + + # EVALUATE GENERATED MESSAGE / EMBED + assert sent_emb.title == 'Help' + assert sent_emb.description == 'List of available commands:' + + embed_fields = sent_emb.fields + debug_field = embed_fields.pop() + assert debug_field.name == 'User Debug Information' + + [d_user, d_roles, d_commands] = debug_field.value.splitlines() + assert d_user == 'Username: `TESTUSER#0002`' + assert all(exp in d_roles for exp in ['unrestricted', 'user']) + assert d_commands == 'Commands Registered: `11`' + + available_commands = sorted([field.name.split(' ')[0] for field in embed_fields]) + expected_commands = sorted([ + 'add-user', + 'echo', + 'reject', + 'error', + 'delete-user', + 'log', + 'test', + 'show', + 'movie', + 'add-admin', + 'help' + ]) + + assert available_commands == expected_commands + +@pytest.mark.asyncio +@pytest.mark.django_db(transaction=True) +async def test_help_nonuser_with_debug(bot): + # SET DEBUG TO TRUE IN CONFIG + await common.edit_configuration(is_debug=True) + + # GET DISCORD USER + TEST_NONUSER = common.get_user_by_name('TESTNONUSER') + + # USER ISSUES COMMAND + await dpytest.message(content=TEST_MESSAGE, member=TEST_NONUSER) + await dpytest.run_all_events() + + # BOT RESPONDS WITH MESSAGE / EMBED + sent_emb = dpytest.get_embed(peek=True) + + # EVALUATE GENERATED MESSAGE / EMBED + assert sent_emb.title == 'Help' + assert sent_emb.description == 'List of available commands:' + + embed_fields = sent_emb.fields + debug_field = embed_fields.pop() + assert debug_field.name == 'User Debug Information' + + [d_user, d_roles, d_commands] = debug_field.value.splitlines() + assert d_user == 'Username: `TESTNONUSER#0003`' + assert all(exp in d_roles for exp in ['unregistered']) + assert d_commands == 'Commands Registered: `11`' + + available_commands = sorted([field.name.split(' ')[0] for field in embed_fields]) + expected_commands = sorted([ + 'add-user', + 'echo', + 'reject', + 'error', + 'delete-user', + 'log', + 'test', + 'show', + 'movie', + 'add-admin', + 'help' + ]) + + assert available_commands == expected_commands + \ No newline at end of file diff --git a/DiscoFlixClient/tests/common/common.py b/DiscoFlixClient/tests/common/common.py new file mode 100644 index 0000000..76a96f4 --- /dev/null +++ b/DiscoFlixClient/tests/common/common.py @@ -0,0 +1,32 @@ +import discord.ext.test.runner as runner +import pytest_asyncio +import pytest +import discord.ext.test as dpytest +from DiscoFlixClient import utils +from channels.db import database_sync_to_async +import logging + +LOGGER = logging.getLogger(__name__) + +def get_user_by_name(name): + test_config = runner.get_config() + users = test_config.guilds[0].channels[0].members + test_user = next((u for u in users if u.name == name)) + return test_user + +@pytest.mark.django_db(transaction=True) +@database_sync_to_async +def edit_configuration(**kwargs): + config = utils.get_config_sync() + if not config: + LOGGER.error('CONFIGURATION DOESNT EXIST IN DATABASE, CHECK FIXTURES') + return + changed = [] + for k, v in kwargs.items(): + if hasattr(config, k): + attribute = getattr(config, k) + if str(attribute) != str(v) and (attribute or v): + changed.append(k) + setattr(config, k, v) + config.save() + return config \ No newline at end of file diff --git a/DiscoFlixClient/tests/conftest.py b/DiscoFlixClient/tests/conftest.py new file mode 100644 index 0000000..2693301 --- /dev/null +++ b/DiscoFlixClient/tests/conftest.py @@ -0,0 +1,87 @@ +from dotenv import load_dotenv +load_dotenv() + +import os +import pytest_asyncio +import pytest +import discord.ext.test as dpytest +from DiscoFlixClient.models import Configuration, User +from channels.db import database_sync_to_async +from DiscoFlixBot.bot import DiscordBot +from django.core.management import call_command + + +@pytest.mark.django_db(transaction=True) +@database_sync_to_async +def mock_configuration(): + print('--- CREATING CONFIGURATION IN DB ----') + config = Configuration.objects.first() + if not config: + config = Configuration.objects.create() + config.media_server_name="DiscoFlix Test Server" + config.discord_token = os.getenv('TEST_DISCORD_TOKEN') + config.radarr_url = os.getenv('TEST_RADARR_URL') + config.radarr_token = os.getenv('TEST_RADARR_TOKEN') + config.sonarr_url = os.getenv('TEST_SONARR_URL') + config.sonarr_token = os.getenv('TEST_SONARR_TOKEN') + config.save + return config + +@pytest.mark.django_db(transaction=True) +@database_sync_to_async +def mock_users(): + print('--- CREATING USER(S) IN DB ----') + User.objects.create( + username='TESTADMIN#0001', + password='Password1234!', + is_admin=True, + is_server_restricted=False, + is_superuser=True, + is_staff=True, + ) + User.objects.create( + username='TESTUSER#0002', + password='Password1234!', + is_admin=False, + is_server_restricted=False, + is_superuser=False, + is_staff=False, + ) + + +@pytest_asyncio.fixture +@pytest.mark.django_db(transaction=True) +async def bot(): + call_command('initializedb') + + await mock_users() + await mock_configuration() + + print('--- INSTANTIATING BOT ----') + DF_BOT = DiscordBot() + DF_CLIENT = DF_BOT.client + await DF_CLIENT._async_setup_hook() + dpytest.configure(DF_CLIENT) + + # USERS JOINS SERVER + guild = DF_CLIENT.guilds[0] + discord_users = [ + 'TESTADMIN', + 'TESTUSER', + 'TESTNONUSER' + ] + for i, username in enumerate(discord_users): + await dpytest.member_join( + guild=guild, + user=None, + name=username, + discrim=i+1 + ) + yield DF_CLIENT + await dpytest.empty_queue() + + +def pytest_sessionfinish(session, exitstatus): + print("--- TESTING CONCLUDED ---\n\n", session, exitstatus) + """ Code to execute after all tests. """ + diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..db317ce --- /dev/null +++ b/pytest.ini @@ -0,0 +1,20 @@ +# pytest.ini +[pytest] +DJANGO_SETTINGS_MODULE = DiscoFlix.settings +log_cli=true +# Paths +testpaths = + DiscoFlixClient/tests + +# Files +python_files = tests.py test_*.py *_tests.py + +# Asyncio +asyncio_mode = strict + +# Additional options +addopts = + -ra + -v + --strict-markers + --tb=long \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index acd5377..852b5f5 100644 --- a/requirements.txt +++ b/requirements.txt @@ -21,6 +21,7 @@ Django==4.2.4 django-cors-headers==4.2.0 django-jazzmin==2.6.0 djangorestframework==3.14.0 +dpytest==0.7.0 drf-spectacular==0.26.5 drf-yasg==1.21.7 frozenlist==1.4.0 @@ -28,7 +29,9 @@ hyperlink==21.0.0 idna==3.4 incremental==22.10.0 inflection==0.5.1 +iniconfig==2.0.0 itypes==1.2.0 +jellyfin-apiclient-python==1.9.2 Jinja2==3.1.2 jsonschema==4.19.1 jsonschema-specifications==2023.7.1 @@ -36,10 +39,16 @@ MarkupSafe==2.1.3 multidict==6.0.4 netifaces==0.10.6 packaging==23.1 +PlexAPI==4.15.6 +pluggy==1.3.0 pyasn1==0.5.0 pyasn1-modules==0.3.0 pycparser==2.21 pyOpenSSL==23.2.0 +pytest==7.4.4 +pytest-asyncio==0.23.3 +pytest-django==4.7.0 +python-dotenv==1.0.0 python-engineio==4.7.0 python-socketio==5.9.0 pytz==2023.3.post1 @@ -57,6 +66,7 @@ txaio==23.1.1 typing_extensions==4.7.1 uritemplate==4.1.1 urllib3==2.0.4 +websocket-client==1.6.4 websockets==11.0.3 whitenoise==6.5.0 yarl==1.9.2