Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
name: Tests

on:
push:
branches: [main]
pull_request:

jobs:
pytest:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
# 3.12 is what Ubuntu 24.04 ships, which is what the bot is deployed on.
python-version: ["3.11", "3.12"]

steps:
- uses: actions/checkout@v4

- uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
cache: pip

- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements-dev.txt

- name: Run tests
# The suite needs no credentials, no network and no .env.
run: pytest
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,18 @@ python main.py

`!lyrics` is optional — set `GENIUS_TOKEN` in `.env` to enable it.

### Running the tests
```bash
.venv/bin/pip install -r requirements-dev.txt # Windows: .venv\Scripts\pip
pytest
```

The suite needs **no credentials, no network and no `.env`** — it mocks the
Discord gateway and never calls yt-dlp. It covers queue and player state, the
`_advance` state machine (loop modes, skip, replay, autoplay, idle timeout),
the `services.media` helpers, the voice-state guards, and the command edge
cases. CI runs it on every push and pull request against Python 3.11 and 3.12.

### Deploy to AWS (t4g.micro, ~$6/mo or free tier)
See **[deploy/README.md](deploy/README.md)** for a full walkthrough: launch script,
provisioning (`deploy/setup.sh`) and a `systemd` service that auto-restarts and
Expand Down
8 changes: 8 additions & 0 deletions pytest.ini
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
[pytest]
testpaths = tests
asyncio_mode = auto
# Every async fixture lives for one test only — players hold event-loop state.
asyncio_default_fixture_loop_scope = function
filterwarnings =
error::RuntimeWarning
addopts = -q --strict-markers
5 changes: 5 additions & 0 deletions requirements-dev.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# Test-only dependencies. Install with:
# pip install -r requirements.txt -r requirements-dev.txt
-r requirements.txt
pytest>=8.0
pytest-asyncio>=0.24
77 changes: 77 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
"""
Shared fixtures.

The suite never opens a Discord gateway connection and never touches the
network. Everything here builds just enough of a fake bot/guild for the real
:class:`~utils.player.MusicPlayer` logic to run in-process.
"""

import sys
from pathlib import Path
from unittest.mock import MagicMock

import pytest

# Make the project importable without installing it.
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))

from utils.player import MusicPlayer, players # noqa: E402


def make_track(title: str = "Track", **overrides) -> dict:
"""A track dict shaped like the one ``services.media._build_track`` returns."""
track = {
"title": title,
"url": f"https://example.invalid/{title.replace(' ', '_')}",
"stream": None,
"duration": 180,
"thumbnail": None,
"uploader": "Uploader",
"source": "test",
"query": "",
}
track.update(overrides)
return track


@pytest.fixture
def track_factory():
return make_track


@pytest.fixture
def fake_bot():
"""
A stand-in for the bot.

``create_task`` deliberately closes the coroutine instead of scheduling it:
unit tests drive ``_advance`` and the queue directly, and letting the real
``_player_loop`` run would try to touch voice state.
"""
bot = MagicMock()

def _create_task(coro):
coro.close()
task = MagicMock()
task.done.return_value = True
return task

bot.loop.create_task.side_effect = _create_task
return bot


@pytest.fixture
def fake_guild():
guild = MagicMock()
guild.id = 1234567890
guild.voice_client = None # not connected unless a test says otherwise
return guild


@pytest.fixture
def player(fake_bot, fake_guild):
"""A MusicPlayer whose background loop is never started."""
p = MusicPlayer(fake_bot, fake_guild, MagicMock())
yield p
# Keep the module-level singleton clean between tests.
players.discard(fake_guild.id)
98 changes: 98 additions & 0 deletions tests/test_checks.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
"""
Voice-state guards in ``utils.checks``.

``commands.check`` exposes the wrapped predicate as ``.predicate``, so each
guard can be exercised directly. Every guard must both return the right verdict
*and* tell the user why it refused - a silent False is a bug.
"""

from unittest.mock import AsyncMock, MagicMock

import pytest

from utils.checks import user_in_voice, bot_in_voice, same_voice_channel


@pytest.fixture
def ctx():
c = MagicMock()
c.send = AsyncMock()
c.author.voice = None
c.voice_client = None
return c


def in_channel(channel):
"""A voice state for someone sitting in ``channel``."""
state = MagicMock()
state.channel = channel
return state


def sent_text(ctx) -> str:
embed = ctx.send.await_args.kwargs.get("embed") or ctx.send.await_args.args[0]
return embed.description or ""


# -- user_in_voice -----------------------------------------------------

async def test_user_in_voice_passes_when_connected(ctx):
ctx.author.voice = in_channel(MagicMock())
assert await user_in_voice().predicate(ctx) is True
ctx.send.assert_not_awaited()


async def test_user_in_voice_refuses_and_explains_when_not_connected(ctx):
assert await user_in_voice().predicate(ctx) is False
assert "must be in a voice channel" in sent_text(ctx)


async def test_user_in_voice_refuses_a_stale_voice_state(ctx):
"""A voice state with no channel means the user just left."""
ctx.author.voice = in_channel(None)
assert await user_in_voice().predicate(ctx) is False


# -- bot_in_voice ------------------------------------------------------

async def test_bot_in_voice_passes_when_the_bot_is_connected(ctx):
ctx.voice_client = MagicMock()
assert await bot_in_voice().predicate(ctx) is True


async def test_bot_in_voice_refuses_and_explains_when_the_bot_is_not(ctx):
assert await bot_in_voice().predicate(ctx) is False
assert "not connected" in sent_text(ctx)


# -- same_voice_channel ------------------------------------------------

async def test_same_channel_passes(ctx):
channel = MagicMock()
ctx.author.voice = in_channel(channel)
ctx.voice_client = MagicMock(channel=channel)
assert await same_voice_channel().predicate(ctx) is True
ctx.send.assert_not_awaited()


async def test_different_channel_is_refused(ctx):
ctx.author.voice = in_channel(MagicMock())
ctx.voice_client = MagicMock(channel=MagicMock())
assert await same_voice_channel().predicate(ctx) is False
assert "same voice channel" in sent_text(ctx)


async def test_same_channel_requires_the_user_to_be_in_voice(ctx):
ctx.voice_client = MagicMock(channel=MagicMock())
assert await same_voice_channel().predicate(ctx) is False
assert "must be in a voice channel" in sent_text(ctx)


async def test_same_channel_passes_when_the_bot_is_not_connected_yet(ctx):
"""
With no voice client there is no channel to mismatch, so the guard defers to
the command, which reports "nothing is playing" itself.
"""
ctx.author.voice = in_channel(MagicMock())
ctx.voice_client = None
assert await same_voice_channel().predicate(ctx) is True
Loading
Loading