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
25 changes: 25 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
name: CI

on:
push:
pull_request:

jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4

- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.11"

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

- name: Run pytest
run: pytest
3 changes: 3 additions & 0 deletions pytest.ini
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
[pytest]
asyncio_mode = auto
testpaths = tests
3 changes: 3 additions & 0 deletions requirements-dev.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
-r requirements.txt
pytest>=8.0
pytest-asyncio>=0.24
Empty file added tests/__init__.py
Empty file.
169 changes: 169 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
"""Shared fixtures and mock-object factories for the YapHub test suite.

Mocking conventions (established by this repo's prior one-off manual
verification scripts, followed here rather than inventing a new style):

- discord.py model objects (Member, VoiceChannel, Guild, Interaction,
Message, ...) are built with unittest.mock.Mock(spec=discord.X). The
spec= matters: it makes isinstance() checks against discord.X succeed,
which several functions under test rely on (e.g. `isinstance(channel,
discord.VoiceChannel)`).
- Async methods on those mocks are AsyncMock().
- Lightweight stand-ins for `bot` and other non-discord collaborators use
types.SimpleNamespace.
"""

from __future__ import annotations

import sys
from pathlib import Path
from unittest.mock import AsyncMock, Mock

import discord
import pytest

REPO_ROOT = Path(__file__).resolve().parents[1]
if str(REPO_ROOT) not in sys.path:
sys.path.insert(0, str(REPO_ROOT))


@pytest.fixture
def repo_root() -> Path:
return REPO_ROOT


def make_guild(guild_id: int = 1000, *, name: str = "Test Guild") -> Mock:
guild = Mock(spec=discord.Guild)
guild.id = guild_id
guild.name = name
guild.default_role = Mock(spec=discord.Role)
guild.default_role.id = 0
guild.system_channel = None
guild.get_member = Mock(return_value=None)
guild.get_channel = Mock(return_value=None)
guild.create_voice_channel = AsyncMock()
return guild


def make_member(
member_id: int,
guild: Mock,
*,
manage_channels: bool = False,
is_bot: bool = False,
display_name: str | None = None,
) -> Mock:
member = Mock(spec=discord.Member)
member.id = member_id
member.guild = guild
member.bot = is_bot
member.mention = f"<@{member_id}>"
member.display_name = display_name or f"user{member_id}"
member.voice = None
member.guild_permissions = Mock(spec=discord.Permissions)
member.guild_permissions.manage_channels = manage_channels
member.move_to = AsyncMock()
member.send = AsyncMock()
return member


def make_voice_channel(
channel_id: int,
guild: Mock,
*,
members: list | None = None,
name: str = "Yap Room",
user_limit: int = 0,
category=None,
) -> Mock:
channel = Mock(spec=discord.VoiceChannel)
channel.id = channel_id
channel.guild = guild
channel.members = members if members is not None else []
channel.mention = f"<#{channel_id}>"
channel.name = name
channel.user_limit = user_limit
channel.category = category
channel.overwrites = {}

def _overwrites_for(target):
return channel.overwrites.get(target, discord.PermissionOverwrite())

channel.overwrites_for = Mock(side_effect=_overwrites_for)
channel.set_permissions = AsyncMock()
channel.edit = AsyncMock()
channel.send = AsyncMock()
channel.delete = AsyncMock()
channel.fetch_message = AsyncMock()
return channel


def make_response(*, is_done: bool = False) -> Mock:
response = Mock(spec=discord.InteractionResponse)
response.send_message = AsyncMock()
response.send_modal = AsyncMock()
response.edit_message = AsyncMock()
response.is_done = Mock(return_value=is_done)
return response


def make_interaction(
user: Mock,
guild: Mock | None,
*,
channel=None,
client=None,
) -> Mock:
interaction = Mock(spec=discord.Interaction)
interaction.user = user
interaction.guild = guild
interaction.channel = channel
interaction.client = client
interaction.response = make_response()
interaction.followup = Mock()
interaction.followup.send = AsyncMock()
return interaction


def make_message(message_id: int = 999) -> Mock:
message = Mock(spec=discord.Message)
message.id = message_id
message.edit = AsyncMock()
return message


def make_notfound(status: int = 404, message: str = "Unknown Message") -> discord.NotFound:
import types

response = types.SimpleNamespace(status=status, reason="Not Found")
return discord.NotFound(response, message)


@pytest.fixture
def guild_factory():
return make_guild


@pytest.fixture
def member_factory():
return make_member


@pytest.fixture
def channel_factory():
return make_voice_channel


@pytest.fixture
def interaction_factory():
return make_interaction


@pytest.fixture
def message_factory():
return make_message


@pytest.fixture
def notfound_factory():
return make_notfound
40 changes: 40 additions & 0 deletions tests/test_circular_import.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
"""Regression test for the deliberate circular-import hazard between
services/panel.py (imports apply_* from services/room_actions.py at module
scope) and services/room_actions.py (imports refresh_panel_message from
services/panel.py lazily, inside function bodies, specifically to avoid a
circular import).

Normal pytest collection imports every test module (and transitively every
module under test) in whatever order pytest discovers files, which can
happen to import one of these two modules first and mask a regression.
Running each import order in a fresh subprocess makes sure a change that
reintroduces the cycle at module scope is caught regardless of collection
order.
"""

from __future__ import annotations

import subprocess
import sys

from tests.conftest import REPO_ROOT


def _run(code: str) -> subprocess.CompletedProcess:
return subprocess.run(
[sys.executable, "-c", code],
cwd=str(REPO_ROOT),
capture_output=True,
text=True,
timeout=30,
)


def test_import_room_actions_then_panel():
result = _run("import services.room_actions; import services.panel")
assert result.returncode == 0, result.stderr


def test_import_panel_then_room_actions():
result = _run("import services.panel; import services.room_actions")
assert result.returncode == 0, result.stderr
64 changes: 64 additions & 0 deletions tests/test_command_tree.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
"""Sanity check that the slash-command tree builds cleanly: every expected
command name exists exactly once, with no name collisions.

This does not import bot.py (which calls bot.run(TOKEN) at import time and
would require a real Discord token / network connection). Instead it builds
a bare discord.ext.commands.Bot and wires up the same YapGroup that bot.py
registers in setup_hook.
"""

from __future__ import annotations

import discord
from discord import app_commands
from discord.ext import commands

from commands import YapGroup

EXPECTED_COMMAND_NAMES = {
"yap setup",
"yap help",
"yap config",
"yap reset",
"yap rename",
"yap limit",
"yap transfer",
"yap lock",
"yap unlock",
"yap hide",
"yap unhide",
"yap room",
"yap permit",
"yap unpermit",
"yap profile create",
"yap profile list",
"yap profile delete",
}


def _make_fake_bot() -> commands.Bot:
intents = discord.Intents.default()
intents.voice_states = True
intents.guilds = True
intents.members = True
return commands.Bot(command_prefix="!", intents=intents)


def test_command_tree_has_all_expected_commands_with_no_collisions():
bot = _make_fake_bot()
bot.tree.add_command(YapGroup(bot))

# walk_commands() yields both leaf commands and the Group nodes
# ("yap", "yap profile") that contain them; only leaf commands are
# user-invocable slash commands, so groups are excluded from the
# expected-name comparison.
seen_names: list[str] = [
command.qualified_name
for command in bot.tree.walk_commands()
if isinstance(command, app_commands.Command)
]

assert len(seen_names) == len(set(seen_names)), (
f"Duplicate command names registered: {seen_names}"
)
assert set(seen_names) == EXPECTED_COMMAND_NAMES
Loading
Loading