diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..c04b2c2 --- /dev/null +++ b/.github/workflows/ci.yml @@ -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 diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..78c5011 --- /dev/null +++ b/pytest.ini @@ -0,0 +1,3 @@ +[pytest] +asyncio_mode = auto +testpaths = tests diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..7df8cd2 --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,3 @@ +-r requirements.txt +pytest>=8.0 +pytest-asyncio>=0.24 diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..acee23a --- /dev/null +++ b/tests/conftest.py @@ -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 diff --git a/tests/test_circular_import.py b/tests/test_circular_import.py new file mode 100644 index 0000000..0cb89e5 --- /dev/null +++ b/tests/test_circular_import.py @@ -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 diff --git a/tests/test_command_tree.py b/tests/test_command_tree.py new file mode 100644 index 0000000..86f8c0d --- /dev/null +++ b/tests/test_command_tree.py @@ -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 diff --git a/tests/test_ownership.py b/tests/test_ownership.py new file mode 100644 index 0000000..4c73065 --- /dev/null +++ b/tests/test_ownership.py @@ -0,0 +1,209 @@ +"""Tests for services/ownership.py's authorization gate. + +_authorize_channel is the single choke point used by both +resolve_owned_temp_channel (slash commands, voice-state based) and +resolve_owned_temp_channel_by_id (panel buttons, channel-id based): +- the recorded owner is always allowed +- a non-owner needs Manage Channels AND to be physically connected to the + room (channel.members) -- the presence check closes a bug where an admin + elsewhere in the server could silently manage a room they never joined. +""" + +from __future__ import annotations + +import types +from unittest.mock import AsyncMock + +import pytest + +from services.ownership import ( + _authorize_channel, + resolve_owned_temp_channel, + resolve_owned_temp_channel_by_id, +) +from tests.conftest import make_interaction, make_member, make_voice_channel + +NOT_TRACKED_MESSAGE = "That voice channel is not a tracked YapHub temp room." +NOT_OWNER_OR_ADMIN_MESSAGE = "Only the room owner or a Manage Channels admin can do that." +NOT_PRESENT_MESSAGE = "You must be connected to this voice channel to manage it as an admin." + + +def _record(guild_id: int, owner_id: int) -> dict: + return {"guild_id": str(guild_id), "owner_user_id": str(owner_id)} + + +def _storage(record) -> AsyncMock: + storage = AsyncMock() + storage.get_active_temp_channel = AsyncMock(return_value=record) + return storage + + +@pytest.fixture +def guild(guild_factory): + return guild_factory(guild_id=1) + + +async def test_owner_is_allowed_even_when_absent_from_channel(guild): + owner = make_member(1, guild, manage_channels=False) + channel = make_voice_channel(500, guild, members=[]) # owner not present + interaction = make_interaction(owner, guild) + storage = _storage(_record(guild.id, owner.id)) + + allowed = await _authorize_channel(interaction, storage, channel) + + assert allowed is True + interaction.response.send_message.assert_not_called() + + +async def test_non_owner_admin_present_is_allowed(guild): + owner_id = 1 + admin = make_member(2, guild, manage_channels=True) + channel = make_voice_channel(500, guild, members=[admin]) + interaction = make_interaction(admin, guild) + storage = _storage(_record(guild.id, owner_id)) + + allowed = await _authorize_channel(interaction, storage, channel) + + assert allowed is True + interaction.response.send_message.assert_not_called() + + +async def test_non_owner_admin_absent_is_denied_with_presence_message(guild): + owner_id = 1 + admin = make_member(2, guild, manage_channels=True) + channel = make_voice_channel(500, guild, members=[]) # admin not connected + interaction = make_interaction(admin, guild) + storage = _storage(_record(guild.id, owner_id)) + + allowed = await _authorize_channel(interaction, storage, channel) + + assert allowed is False + interaction.response.send_message.assert_awaited_once_with( + NOT_PRESENT_MESSAGE, ephemeral=True + ) + + +async def test_non_owner_non_admin_is_denied_regardless_of_presence(guild): + owner_id = 1 + other = make_member(2, guild, manage_channels=False) + channel = make_voice_channel(500, guild, members=[other]) # even though present + interaction = make_interaction(other, guild) + storage = _storage(_record(guild.id, owner_id)) + + allowed = await _authorize_channel(interaction, storage, channel) + + assert allowed is False + interaction.response.send_message.assert_awaited_once_with( + NOT_OWNER_OR_ADMIN_MESSAGE, ephemeral=True + ) + + +async def test_untracked_channel_is_denied(guild): + member = make_member(1, guild) + channel = make_voice_channel(500, guild, members=[member]) + interaction = make_interaction(member, guild) + storage = _storage(None) + + allowed = await _authorize_channel(interaction, storage, channel) + + assert allowed is False + interaction.response.send_message.assert_awaited_once_with( + NOT_TRACKED_MESSAGE, ephemeral=True + ) + + +async def test_record_from_different_guild_is_denied(guild): + other_guild_id = 999 + member = make_member(1, guild) + channel = make_voice_channel(500, guild, members=[member]) + interaction = make_interaction(member, guild) + storage = _storage(_record(other_guild_id, member.id)) + + allowed = await _authorize_channel(interaction, storage, channel) + + assert allowed is False + interaction.response.send_message.assert_awaited_once_with( + NOT_TRACKED_MESSAGE, ephemeral=True + ) + + +# --- resolve_owned_temp_channel (voice-state based) ------------------------ + + +async def test_resolve_owned_temp_channel_requires_guild_member(): + interaction = make_interaction(user=object(), guild=None) + storage = AsyncMock() + + result = await resolve_owned_temp_channel(interaction, storage) + + assert result is None + interaction.response.send_message.assert_awaited_once_with( + "This command can only be used in a server.", ephemeral=True + ) + + +async def test_resolve_owned_temp_channel_requires_being_in_voice(guild): + member = make_member(1, guild) + member.voice = None + interaction = make_interaction(member, guild) + storage = AsyncMock() + + result = await resolve_owned_temp_channel(interaction, storage) + + assert result is None + interaction.response.send_message.assert_awaited_once_with( + "Join your Yap room before using this command.", ephemeral=True + ) + + +async def test_resolve_owned_temp_channel_returns_channel_for_owner(guild): + member = make_member(1, guild) + channel = make_voice_channel(500, guild, members=[member]) + member.voice = types.SimpleNamespace(channel=channel) + interaction = make_interaction(member, guild) + storage = _storage(_record(guild.id, member.id)) + + result = await resolve_owned_temp_channel(interaction, storage) + + assert result is channel + + +# --- resolve_owned_temp_channel_by_id (panel buttons) ----------------------- + + +async def test_resolve_owned_temp_channel_by_id_requires_guild_member(): + interaction = make_interaction(user=object(), guild=None) + storage = AsyncMock() + + result = await resolve_owned_temp_channel_by_id(interaction, storage, channel_id=500) + + assert result is None + interaction.response.send_message.assert_awaited_once_with( + "This command can only be used in a server.", ephemeral=True + ) + + +async def test_resolve_owned_temp_channel_by_id_channel_gone(guild): + member = make_member(1, guild) + guild.get_channel = lambda channel_id: None + interaction = make_interaction(member, guild) + storage = AsyncMock() + + result = await resolve_owned_temp_channel_by_id(interaction, storage, channel_id=500) + + assert result is None + interaction.response.send_message.assert_awaited_once_with( + "This Yap room no longer exists.", ephemeral=True + ) + + +async def test_resolve_owned_temp_channel_by_id_returns_channel_for_owner(guild): + member = make_member(1, guild) + channel = make_voice_channel(500, guild, members=[member]) + guild.get_channel = lambda channel_id: channel if channel_id == 500 else None + interaction = make_interaction(member, guild) + storage = _storage(_record(guild.id, member.id)) + + result = await resolve_owned_temp_channel_by_id(interaction, storage, channel_id=500) + + assert result is channel diff --git a/tests/test_panel.py b/tests/test_panel.py new file mode 100644 index 0000000..5bab75b --- /dev/null +++ b/tests/test_panel.py @@ -0,0 +1,118 @@ +"""Tests for services/panel.py.""" + +from __future__ import annotations + +import types +from unittest.mock import AsyncMock, patch + +import pytest + +from services.panel import RoomControlPanel, refresh_panel_message +from tests.conftest import make_interaction, make_member, make_message, make_voice_channel + + +@pytest.fixture +def guild(guild_factory): + return guild_factory(guild_id=1) + + +# --- Rename/Limit buttons must not open a modal on denial ----------------- + + +async def test_rename_button_does_not_open_modal_when_resolve_denies(): + view = RoomControlPanel() + interaction = make_interaction(user=make_member(1, None), guild=None) + + with patch.object(RoomControlPanel, "_resolve", new=AsyncMock(return_value=None)): + await view.rename_button.callback(interaction) + + interaction.response.send_modal.assert_not_called() + + +async def test_limit_button_does_not_open_modal_when_resolve_denies(): + view = RoomControlPanel() + interaction = make_interaction(user=make_member(1, None), guild=None) + + with patch.object(RoomControlPanel, "_resolve", new=AsyncMock(return_value=None)): + await view.limit_button.callback(interaction) + + interaction.response.send_modal.assert_not_called() + + +async def test_rename_button_opens_modal_when_resolve_allows(guild): + view = RoomControlPanel() + channel = make_voice_channel(500, guild) + interaction = make_interaction(user=make_member(1, guild), guild=guild) + + with patch.object(RoomControlPanel, "_resolve", new=AsyncMock(return_value=channel)): + await view.rename_button.callback(interaction) + + interaction.response.send_modal.assert_awaited_once() + + +async def test_limit_button_opens_modal_when_resolve_allows(guild): + view = RoomControlPanel() + channel = make_voice_channel(500, guild) + interaction = make_interaction(user=make_member(1, guild), guild=guild) + + with patch.object(RoomControlPanel, "_resolve", new=AsyncMock(return_value=channel)): + await view.limit_button.callback(interaction) + + interaction.response.send_modal.assert_awaited_once() + + +# --- refresh_panel_message -------------------------------------------- + + +def _bot_with_record(record): + storage = types.SimpleNamespace( + get_active_temp_channel=AsyncMock(return_value=record) + ) + return types.SimpleNamespace(storage=storage) + + +async def test_refresh_panel_message_edits_when_found(guild): + message = make_message(message_id=777) + channel = make_voice_channel(500, guild) + channel.fetch_message = AsyncMock(return_value=message) + bot = _bot_with_record({"panel_message_id": "777"}) + owner = make_member(1, guild) + + await refresh_panel_message(bot, channel, owner) + + channel.fetch_message.assert_awaited_once_with(777) + message.edit.assert_awaited_once() + _, kwargs = message.edit.call_args + assert kwargs["embed"].title == "Yap Room Controls" + + +async def test_refresh_panel_message_noop_when_panel_message_id_none(guild): + channel = make_voice_channel(500, guild) + bot = _bot_with_record({"panel_message_id": None}) + owner = make_member(1, guild) + + await refresh_panel_message(bot, channel, owner) + + channel.fetch_message.assert_not_called() + + +async def test_refresh_panel_message_noop_when_record_missing(guild): + channel = make_voice_channel(500, guild) + bot = _bot_with_record(None) + owner = make_member(1, guild) + + await refresh_panel_message(bot, channel, owner) + + channel.fetch_message.assert_not_called() + + +async def test_refresh_panel_message_swallows_not_found(guild, notfound_factory): + channel = make_voice_channel(500, guild) + channel.fetch_message = AsyncMock(side_effect=notfound_factory()) + bot = _bot_with_record({"panel_message_id": "777"}) + owner = make_member(1, guild) + + # Must not raise. + await refresh_panel_message(bot, channel, owner) + + channel.fetch_message.assert_awaited_once() diff --git a/tests/test_py_compile.py b/tests/test_py_compile.py new file mode 100644 index 0000000..f0c2ea7 --- /dev/null +++ b/tests/test_py_compile.py @@ -0,0 +1,29 @@ +"""Basic syntax sanity check: every tracked .py file in the repo must at +least compile. This catches stray syntax errors that unit tests covering +only a subset of modules could otherwise miss. +""" + +from __future__ import annotations + +import py_compile +import subprocess + +import pytest + +from tests.conftest import REPO_ROOT + + +def _tracked_python_files() -> list[str]: + result = subprocess.run( + ["git", "ls-files", "*.py"], + cwd=str(REPO_ROOT), + capture_output=True, + text=True, + check=True, + ) + return [line for line in result.stdout.splitlines() if line] + + +@pytest.mark.parametrize("relative_path", _tracked_python_files()) +def test_file_compiles(relative_path: str): + py_compile.compile(str(REPO_ROOT / relative_path), doraise=True) diff --git a/tests/test_room_actions.py b/tests/test_room_actions.py new file mode 100644 index 0000000..fca7818 --- /dev/null +++ b/tests/test_room_actions.py @@ -0,0 +1,262 @@ +"""Tests for services/room_actions.py.""" + +from __future__ import annotations + +import types +from unittest.mock import AsyncMock, patch + +import pytest + +from services.room_actions import apply_claim, apply_kick, apply_transfer +from tests.conftest import make_interaction, make_member, make_voice_channel + + +@pytest.fixture +def guild(guild_factory): + return guild_factory(guild_id=1) + + +def _bot(**storage_overrides): + defaults = dict( + get_active_temp_channel_by_owner=AsyncMock(return_value=None), + transfer_active_temp_channel_owner=AsyncMock(), + remove_permit=AsyncMock(), + ) + defaults.update(storage_overrides) + storage = types.SimpleNamespace(**defaults) + return types.SimpleNamespace(storage=storage) + + +# --- apply_transfer --------------------------------------------------- + + +async def test_apply_transfer_rejects_bot_target(guild): + owner = make_member(1, guild) + target = make_member(2, guild, is_bot=True) + channel = make_voice_channel(500, guild, members=[owner, target]) + interaction = make_interaction(owner, guild) + bot = _bot() + + await apply_transfer(bot, interaction, channel, target) + + bot.storage.transfer_active_temp_channel_owner.assert_not_called() + interaction.response.send_message.assert_awaited_once_with( + "Yap rooms can only be transferred to server members.", ephemeral=True + ) + + +async def test_apply_transfer_rejects_target_not_in_channel(guild): + owner = make_member(1, guild) + target = make_member(2, guild) + channel = make_voice_channel(500, guild, members=[owner]) # target absent + interaction = make_interaction(owner, guild) + bot = _bot() + + await apply_transfer(bot, interaction, channel, target) + + bot.storage.transfer_active_temp_channel_owner.assert_not_called() + interaction.response.send_message.assert_awaited_once_with( + "Transfer target must be in your Yap room.", ephemeral=True + ) + + +async def test_apply_transfer_rejects_target_who_owns_another_room(guild): + owner = make_member(1, guild) + target = make_member(2, guild) + channel = make_voice_channel(500, guild, members=[owner, target]) + interaction = make_interaction(owner, guild) + bot = _bot( + get_active_temp_channel_by_owner=AsyncMock( + return_value={"channel_id": "999"} # a different channel than 500 + ) + ) + + await apply_transfer(bot, interaction, channel, target) + + bot.storage.transfer_active_temp_channel_owner.assert_not_called() + interaction.response.send_message.assert_awaited_once() + args, kwargs = interaction.response.send_message.call_args + assert "already owns another active Yap room" in args[0] + + +async def test_apply_transfer_success_calls_refresh_after_response_exactly_once(guild): + owner = make_member(1, guild) + target = make_member(2, guild) + channel = make_voice_channel(500, guild, members=[owner, target]) + interaction = make_interaction(owner, guild) + bot = _bot() + + call_order: list[str] = [] + interaction.response.send_message.side_effect = lambda *a, **k: call_order.append( + "send_message" + ) + + async def _refresh(*args, **kwargs): + call_order.append("refresh_panel_message") + + with patch("services.panel.refresh_panel_message", new=AsyncMock(side_effect=_refresh)) as refresh_mock: + await apply_transfer(bot, interaction, channel, target) + + bot.storage.transfer_active_temp_channel_owner.assert_awaited_once_with(500, target.id) + refresh_mock.assert_awaited_once_with(bot, channel, target) + assert call_order == ["send_message", "refresh_panel_message"] + + +# --- apply_claim -------------------------------------------------------- + + +def _record(owner_id: int) -> dict: + return {"owner_user_id": str(owner_id)} + + +async def test_apply_claim_untracked_channel_denied(guild): + claimant = make_member(2, guild) + channel = make_voice_channel(500, guild, members=[claimant]) + interaction = make_interaction(claimant, guild) + bot = _bot(get_active_temp_channel=AsyncMock(return_value=None)) + + await apply_claim(bot, interaction, channel) + + interaction.response.send_message.assert_awaited_once_with( + "That voice channel is not a tracked YapHub temp room.", ephemeral=True + ) + + +async def test_apply_claim_requires_claimant_present(guild): + claimant = make_member(2, guild) + channel = make_voice_channel(500, guild, members=[]) # claimant not present + interaction = make_interaction(claimant, guild) + bot = _bot(get_active_temp_channel=AsyncMock(return_value=_record(1))) + + await apply_claim(bot, interaction, channel) + + interaction.response.send_message.assert_awaited_once_with( + "Join the room before claiming it.", ephemeral=True + ) + + +async def test_apply_claim_denied_when_owner_still_present(guild): + owner = make_member(1, guild) + claimant = make_member(2, guild) + channel = make_voice_channel(500, guild, members=[owner, claimant]) + interaction = make_interaction(claimant, guild) + bot = _bot(get_active_temp_channel=AsyncMock(return_value=_record(owner.id))) + + await apply_claim(bot, interaction, channel) + + interaction.response.send_message.assert_awaited_once_with( + "The current owner is still in the room.", ephemeral=True + ) + + +async def test_apply_claim_denied_when_claimant_owns_another_room(guild): + claimant = make_member(2, guild) + channel = make_voice_channel(500, guild, members=[claimant]) # old owner has left + interaction = make_interaction(claimant, guild) + bot = _bot( + get_active_temp_channel=AsyncMock(return_value=_record(1)), + get_active_temp_channel_by_owner=AsyncMock(return_value={"channel_id": "999"}), + ) + + await apply_claim(bot, interaction, channel) + + interaction.response.send_message.assert_awaited_once() + args, _ = interaction.response.send_message.call_args + assert "already own another active Yap room" in args[0] + bot.storage.transfer_active_temp_channel_owner.assert_not_called() + + +async def test_apply_claim_success_calls_refresh_after_response_exactly_once(guild): + claimant = make_member(2, guild) + channel = make_voice_channel(500, guild, members=[claimant]) # old owner has left + interaction = make_interaction(claimant, guild) + bot = _bot(get_active_temp_channel=AsyncMock(return_value=_record(1))) + + call_order: list[str] = [] + interaction.response.send_message.side_effect = lambda *a, **k: call_order.append( + "send_message" + ) + + async def _refresh(*args, **kwargs): + call_order.append("refresh_panel_message") + + with patch("services.panel.refresh_panel_message", new=AsyncMock(side_effect=_refresh)) as refresh_mock: + await apply_claim(bot, interaction, channel) + + bot.storage.transfer_active_temp_channel_owner.assert_awaited_once_with(500, claimant.id) + refresh_mock.assert_awaited_once_with(bot, channel, claimant) + assert call_order == ["send_message", "refresh_panel_message"] + + +# --- apply_kick ----------------------------------------------------------- + + +async def test_apply_kick_rejects_self_kick(guild): + owner = make_member(1, guild) + channel = make_voice_channel(500, guild, members=[owner]) + interaction = make_interaction(owner, guild) + bot = _bot() + + await apply_kick(bot, interaction, channel, owner) + + owner.move_to.assert_not_called() + interaction.response.send_message.assert_awaited_once_with( + "You can't remove yourself from your own room.", ephemeral=True + ) + + +async def test_apply_kick_rejects_member_not_in_channel(guild): + owner = make_member(1, guild) + target = make_member(2, guild) + channel = make_voice_channel(500, guild, members=[owner]) # target absent + interaction = make_interaction(owner, guild) + bot = _bot() + + await apply_kick(bot, interaction, channel, target) + + target.move_to.assert_not_called() + interaction.response.send_message.assert_awaited_once_with( + f"{target.mention} is not in your Yap room.", ephemeral=True + ) + + +async def test_apply_kick_removes_permit_and_revokes_overwrites(guild): + owner = make_member(1, guild) + target = make_member(2, guild) + channel = make_voice_channel(500, guild, members=[owner, target]) + interaction = make_interaction(owner, guild) + bot = _bot() + + with patch( + "services.room_actions.revoke_member_overwrites", new=AsyncMock() + ) as revoke_mock: + await apply_kick(bot, interaction, channel, target) + + target.move_to.assert_awaited_once() + bot.storage.remove_permit.assert_awaited_once_with(500, target.id) + revoke_mock.assert_awaited_once() + interaction.response.send_message.assert_awaited_once_with( + f"Removed {target.mention} from your Yap room.", ephemeral=True + ) + + +async def test_apply_kick_handles_move_failure_gracefully(guild): + import discord + + owner = make_member(1, guild) + target = make_member(2, guild) + target.move_to = AsyncMock(side_effect=discord.HTTPException( + types.SimpleNamespace(status=500, reason="Server Error"), "boom" + )) + channel = make_voice_channel(500, guild, members=[owner, target]) + interaction = make_interaction(owner, guild) + bot = _bot() + + with patch("services.room_actions.revoke_member_overwrites", new=AsyncMock()) as revoke_mock: + await apply_kick(bot, interaction, channel, target) + + bot.storage.remove_permit.assert_not_called() + revoke_mock.assert_not_called() + interaction.response.send_message.assert_awaited_once_with( + "I couldn't remove that member. Check my Move Members permission.", ephemeral=True + ) diff --git a/tests/test_storage.py b/tests/test_storage.py new file mode 100644 index 0000000..e9899e3 --- /dev/null +++ b/tests/test_storage.py @@ -0,0 +1,408 @@ +"""Tests for storage.py's SQLite-backed Storage class. + +Per repo convention, these hit a real temp SQLite file via +tempfile.TemporaryDirectory() rather than mocking the DB layer -- every +prior manual verification script in this repo did the same. +""" + +from __future__ import annotations + +import sqlite3 +import tempfile +from pathlib import Path + +import pytest + +from storage import Storage + + +@pytest.fixture +async def storage(): + with tempfile.TemporaryDirectory() as tmpdir: + db_path = str(Path(tmpdir) / "yaphub.sqlite3") + store = Storage(db_path) + await store.initialize() + yield store + + +# --- guild config ----------------------------------------------------- + + +async def test_get_or_create_guild_config_creates_then_reuses(storage): + created = await storage.get_or_create_guild_config(guild_id=1) + assert created["guild_id"] == "1" + assert created["temp_channel_prefix"] == "" + assert created["notification_cooldown_seconds"] == 45 + + fetched = await storage.get_guild_config(guild_id=1) + assert fetched is not None + assert fetched["guild_id"] == created["guild_id"] + + reused = await storage.get_or_create_guild_config(guild_id=1) + assert reused["created_at"] == created["created_at"] + + +async def test_get_guild_config_missing_returns_none(storage): + assert await storage.get_guild_config(guild_id=999) is None + + +async def test_reset_guild_configuration_clears_profiles_and_config(storage): + await storage.get_or_create_guild_config(guild_id=1) + await storage.create_profile( + guild_id=1, + name="Default", + join_channel_id=10, + target_category_id=None, + created_by_user_id=5, + ) + + await storage.reset_guild_configuration(guild_id=1) + + assert await storage.get_guild_config(guild_id=1) is None + assert await storage.list_profiles(guild_id=1) == [] + + +# --- profiles ----------------------------------------------------------- + + +async def test_create_profile_round_trip(storage): + profile = await storage.create_profile( + guild_id=1, + name="Gaming", + join_channel_id=100, + target_category_id=200, + created_by_user_id=5, + default_user_limit=10, + temp_name_template="{user}'s den", + ) + + assert profile["name"] == "Gaming" + assert profile["guild_id"] == "1" + assert profile["join_channel_id"] == "100" + assert profile["target_category_id"] == "200" + assert profile["created_by_user_id"] == "5" + assert profile["default_user_limit"] == 10 + assert profile["temp_name_template"] == "{user}'s den" + + fetched = await storage.get_profile(profile["id"]) + assert fetched["id"] == profile["id"] + + by_name = await storage.get_profile_by_name(1, "gaming") # case-insensitive + assert by_name["id"] == profile["id"] + + by_join_channel = await storage.get_profile_by_join_channel(1, 100) + assert by_join_channel["id"] == profile["id"] + + +async def test_create_profile_without_optional_fields_defaults_to_none(storage): + profile = await storage.create_profile( + guild_id=1, + name="Default", + join_channel_id=100, + target_category_id=None, + created_by_user_id=5, + ) + assert profile["target_category_id"] is None + assert profile["default_user_limit"] is None + assert profile["temp_name_template"] is None + + +async def test_get_profile_missing_returns_none(storage): + assert await storage.get_profile("does-not-exist") is None + + +async def test_list_profiles_scoped_to_guild_ordered_by_created_at(storage): + p1 = await storage.create_profile( + guild_id=1, name="A", join_channel_id=1, target_category_id=None, created_by_user_id=1 + ) + p2 = await storage.create_profile( + guild_id=1, name="B", join_channel_id=2, target_category_id=None, created_by_user_id=1 + ) + await storage.create_profile( + guild_id=2, name="Other Guild", join_channel_id=3, target_category_id=None, created_by_user_id=1 + ) + + profiles = await storage.list_profiles(guild_id=1) + assert [p["id"] for p in profiles] == [p1["id"], p2["id"]] + + +async def test_list_all_profiles_spans_guilds(storage): + await storage.create_profile( + guild_id=1, name="A", join_channel_id=1, target_category_id=None, created_by_user_id=1 + ) + await storage.create_profile( + guild_id=2, name="B", join_channel_id=2, target_category_id=None, created_by_user_id=1 + ) + + all_profiles = await storage.list_all_profiles() + assert len(all_profiles) == 2 + + +async def test_delete_profile(storage): + profile = await storage.create_profile( + guild_id=1, name="A", join_channel_id=1, target_category_id=None, created_by_user_id=1 + ) + await storage.delete_profile(profile["id"]) + assert await storage.get_profile(profile["id"]) is None + + +# --- active temp channels ------------------------------------------------- + + +async def test_create_and_get_active_temp_channel(storage): + await storage.create_active_temp_channel( + channel_id=500, guild_id=1, profile_id="profile-1", owner_user_id=42 + ) + + record = await storage.get_active_temp_channel(500) + assert record is not None + assert record["channel_id"] == "500" + assert record["guild_id"] == "1" + assert record["profile_id"] == "profile-1" + assert record["owner_user_id"] == "42" + assert record["panel_message_id"] is None + + +async def test_get_active_temp_channel_missing_returns_none(storage): + assert await storage.get_active_temp_channel(12345) is None + + +async def test_get_active_temp_channel_by_owner(storage): + await storage.create_active_temp_channel( + channel_id=500, guild_id=1, profile_id="profile-1", owner_user_id=42 + ) + + record = await storage.get_active_temp_channel_by_owner(1, 42) + assert record["channel_id"] == "500" + + assert await storage.get_active_temp_channel_by_owner(1, 999) is None + + +async def test_list_active_temp_channels_filters_by_guild(storage): + await storage.create_active_temp_channel( + channel_id=500, guild_id=1, profile_id="p", owner_user_id=1 + ) + await storage.create_active_temp_channel( + channel_id=600, guild_id=2, profile_id="p", owner_user_id=2 + ) + + guild_1_rooms = await storage.list_active_temp_channels(guild_id=1) + assert [r["channel_id"] for r in guild_1_rooms] == ["500"] + + all_rooms = await storage.list_active_temp_channels() + assert {r["channel_id"] for r in all_rooms} == {"500", "600"} + + +async def test_transfer_active_temp_channel_owner(storage): + await storage.create_active_temp_channel( + channel_id=500, guild_id=1, profile_id="p", owner_user_id=1 + ) + await storage.transfer_active_temp_channel_owner(500, 99) + + record = await storage.get_active_temp_channel(500) + assert record["owner_user_id"] == "99" + + +async def test_touch_active_temp_channel_updates_last_seen(storage): + await storage.create_active_temp_channel( + channel_id=500, guild_id=1, profile_id="p", owner_user_id=1 + ) + original = await storage.get_active_temp_channel(500) + + await storage.touch_active_temp_channel(500) + touched = await storage.get_active_temp_channel(500) + + # last_seen_at is second-resolution ISO; equality is a reasonable + # sanity check that the column round-trips even if the clock didn't tick. + assert touched["last_seen_at"] >= original["last_seen_at"] + + +async def test_delete_active_temp_channel_also_clears_permits(storage): + await storage.create_active_temp_channel( + channel_id=500, guild_id=1, profile_id="p", owner_user_id=1 + ) + await storage.add_permit(500, 7) + assert len(await storage.list_permits(500)) == 1 + + await storage.delete_active_temp_channel(500) + + assert await storage.get_active_temp_channel(500) is None + assert await storage.list_permits(500) == [] + + +# --- permits -------------------------------------------------------------- + + +async def test_add_list_remove_permit(storage): + await storage.create_active_temp_channel( + channel_id=500, guild_id=1, profile_id="p", owner_user_id=1 + ) + await storage.add_permit(500, 7) + await storage.add_permit(500, 8) + + permits = await storage.list_permits(500) + assert {p["user_id"] for p in permits} == {"7", "8"} + + await storage.remove_permit(500, 7) + permits = await storage.list_permits(500) + assert {p["user_id"] for p in permits} == {"8"} + + +async def test_add_permit_is_idempotent(storage): + await storage.create_active_temp_channel( + channel_id=500, guild_id=1, profile_id="p", owner_user_id=1 + ) + await storage.add_permit(500, 7) + await storage.add_permit(500, 7) # insert or ignore -- must not raise or duplicate + + permits = await storage.list_permits(500) + assert len(permits) == 1 + + +# --- panel_message_id ------------------------------------------------- + + +async def test_set_and_get_panel_message_id(storage): + await storage.create_active_temp_channel( + channel_id=500, guild_id=1, profile_id="p", owner_user_id=1 + ) + await storage.set_panel_message_id(500, 123456789) + + record = await storage.get_active_temp_channel(500) + assert record["panel_message_id"] == "123456789" + + +async def test_panel_message_id_cleared_when_room_deleted(storage): + await storage.create_active_temp_channel( + channel_id=500, guild_id=1, profile_id="p", owner_user_id=1 + ) + await storage.set_panel_message_id(500, 123456789) + + await storage.delete_active_temp_channel(500) + + assert await storage.get_active_temp_channel(500) is None + # Recreating the room (as would happen for a fresh temp channel with the + # same id, however unlikely) must not resurrect the stale panel id. + await storage.create_active_temp_channel( + channel_id=500, guild_id=1, profile_id="p", owner_user_id=1 + ) + record = await storage.get_active_temp_channel(500) + assert record["panel_message_id"] is None + + +# --- idempotent initialize -------------------------------------------- + + +async def test_double_initialize_is_idempotent(storage): + # `storage` fixture already called initialize() once; call again and + # confirm no error and existing data survives. + profile = await storage.create_profile( + guild_id=1, name="A", join_channel_id=1, target_category_id=None, created_by_user_id=1 + ) + + await storage.initialize() + + assert await storage.get_profile(profile["id"]) is not None + + +# --- guarded migration (_migrate) -------------------------------------- + + +def _create_pre_migration_schema(db_path: str) -> None: + """Build a database matching the schema as it existed before + default_user_limit/temp_name_template/panel_message_id were added, + with a row in each affected table, to exercise the guarded + `alter table ... add column` migration path in Storage._migrate. + """ + connection = sqlite3.connect(db_path) + try: + connection.executescript( + """ + create table guild_configs ( + guild_id text primary key, + temp_channel_prefix text not null default '', + notification_cooldown_seconds integer not null default 45, + created_at text not null, + updated_at text not null + ); + + create table temp_vc_profiles ( + id text primary key, + guild_id text not null, + name text not null, + join_channel_id text not null unique, + target_category_id text, + created_by_user_id text not null, + created_at text not null, + updated_at text not null + ); + + create table active_temp_channels ( + channel_id text primary key, + guild_id text not null, + profile_id text not null, + owner_user_id text not null, + created_at text not null, + last_seen_at text not null + ); + + create table temp_channel_permits ( + channel_id text not null, + user_id text not null, + created_at text not null, + primary key (channel_id, user_id) + ); + """ + ) + connection.execute( + """ + insert into temp_vc_profiles ( + id, guild_id, name, join_channel_id, target_category_id, + created_by_user_id, created_at, updated_at + ) values ('profile-1', '1', 'Legacy', '10', null, '5', 'then', 'then') + """ + ) + connection.execute( + """ + insert into active_temp_channels ( + channel_id, guild_id, profile_id, owner_user_id, created_at, last_seen_at + ) values ('500', '1', 'profile-1', '42', 'then', 'then') + """ + ) + connection.commit() + finally: + connection.close() + + +async def test_migrate_adds_missing_columns_and_preserves_data(): + with tempfile.TemporaryDirectory() as tmpdir: + db_path = str(Path(tmpdir) / "legacy.sqlite3") + _create_pre_migration_schema(db_path) + + store = Storage(db_path) + await store.initialize() + + profile = await store.get_profile("profile-1") + assert profile is not None + assert profile["name"] == "Legacy" + # New columns exist and default to NULL for pre-existing rows. + assert profile["default_user_limit"] is None + assert profile["temp_name_template"] is None + + channel = await store.get_active_temp_channel(500) + assert channel is not None + assert channel["owner_user_id"] == "42" + assert channel["panel_message_id"] is None + + # The migrated columns are now fully usable going forward. + await store.set_panel_message_id(500, 999) + channel = await store.get_active_temp_channel(500) + assert channel["panel_message_id"] == "999" + + +async def test_migrate_is_a_no_op_when_columns_already_present(storage): + # `storage` fixture already ran the full current schema + migration. + # Running initialize() again must not error even though every guarded + # column already exists. + await storage.initialize() + await storage.initialize() diff --git a/tests/test_temp_channels.py b/tests/test_temp_channels.py new file mode 100644 index 0000000..26f7ecd --- /dev/null +++ b/tests/test_temp_channels.py @@ -0,0 +1,212 @@ +"""Tests for services/temp_channels.py.""" + +from __future__ import annotations + +import asyncio +import types +from unittest.mock import AsyncMock, Mock, patch + +import discord +import pytest + +from services.temp_channels import create_temp_room, reconcile_active_temp_channels +from tests.conftest import make_guild, make_member, make_message, make_voice_channel + + +def _row(**overrides) -> dict: + row = { + "channel_id": "500", + "guild_id": "1", + "profile_id": "profile-1", + "owner_user_id": "42", + "panel_message_id": None, + } + row.update(overrides) + return row + + +def _make_bot(*, guild=None, get_guild=None, fetch_channel=None): + storage = types.SimpleNamespace( + list_active_temp_channels=AsyncMock(return_value=[]), + delete_active_temp_channel=AsyncMock(), + touch_active_temp_channel=AsyncMock(), + set_panel_message_id=AsyncMock(), + ) + bot = types.SimpleNamespace( + storage=storage, + get_guild=get_guild or Mock(return_value=guild), + fetch_channel=fetch_channel or AsyncMock(return_value=None), + active_temp_channel_ids=set(), + ) + return bot + + +# --- reconcile_active_temp_channels: stale records ---------------------- + + +async def test_reconcile_deletes_record_for_missing_guild(): + row = _row() + bot = _make_bot(get_guild=Mock(return_value=None)) + bot.storage.list_active_temp_channels = AsyncMock(return_value=[row]) + + await reconcile_active_temp_channels(bot) + + bot.storage.delete_active_temp_channel.assert_awaited_once_with(500) + assert bot.active_temp_channel_ids == set() + + +async def test_reconcile_deletes_record_for_missing_channel(): + row = _row() + guild = make_guild(1) + guild.get_channel = Mock(return_value=None) + bot = _make_bot(guild=guild, fetch_channel=AsyncMock(side_effect=discord.NotFound( + types.SimpleNamespace(status=404, reason="Not Found"), "Unknown Channel" + ))) + bot.storage.list_active_temp_channels = AsyncMock(return_value=[row]) + + await reconcile_active_temp_channels(bot) + + bot.storage.delete_active_temp_channel.assert_awaited_once_with(500) + assert bot.active_temp_channel_ids == set() + + +async def test_reconcile_deletes_empty_room(): + row = _row() + guild = make_guild(1) + channel = make_voice_channel(500, guild, members=[]) + guild.get_channel = Mock(return_value=channel) + bot = _make_bot(guild=guild) + bot.storage.list_active_temp_channels = AsyncMock(return_value=[row]) + + await reconcile_active_temp_channels(bot) + + channel.delete.assert_awaited_once() + bot.storage.delete_active_temp_channel.assert_awaited_once_with(500) + assert bot.active_temp_channel_ids == set() + + +async def test_reconcile_keeps_and_touches_non_empty_room(): + row = _row(panel_message_id="777") # already has a panel -- no backfill + guild = make_guild(1) + member = make_member(9, guild) + channel = make_voice_channel(500, guild, members=[member]) + guild.get_channel = Mock(return_value=channel) + bot = _make_bot(guild=guild) + bot.storage.list_active_temp_channels = AsyncMock(return_value=[row]) + + with patch("services.temp_channels.send_room_panel", new=AsyncMock()) as send_panel: + await reconcile_active_temp_channels(bot) + + bot.storage.touch_active_temp_channel.assert_awaited_once_with(500) + bot.storage.delete_active_temp_channel.assert_not_called() + send_panel.assert_not_called() + assert bot.active_temp_channel_ids == {500} + + +# --- panel_message_id backfill ------------------------------------------- + + +async def test_reconcile_backfills_panel_message_when_owner_present(): + row = _row(panel_message_id=None) + guild = make_guild(1) + owner = make_member(42, guild) + guild.get_member = Mock(return_value=owner) + channel = make_voice_channel(500, guild, members=[owner]) + guild.get_channel = Mock(return_value=channel) + bot = _make_bot(guild=guild) + bot.storage.list_active_temp_channels = AsyncMock(return_value=[row]) + + panel_message = make_message(message_id=888) + with patch( + "services.temp_channels.send_room_panel", new=AsyncMock(return_value=panel_message) + ) as send_panel: + await reconcile_active_temp_channels(bot) + + send_panel.assert_awaited_once_with(channel, owner) + bot.storage.set_panel_message_id.assert_awaited_once_with(500, 888) + + +async def test_reconcile_skips_backfill_when_owner_left_guild(): + row = _row(panel_message_id=None) + guild = make_guild(1) + guild.get_member = Mock(return_value=None) # owner no longer in guild + member = make_member(9, guild) + channel = make_voice_channel(500, guild, members=[member]) + guild.get_channel = Mock(return_value=channel) + bot = _make_bot(guild=guild) + bot.storage.list_active_temp_channels = AsyncMock(return_value=[row]) + + with patch("services.temp_channels.send_room_panel", new=AsyncMock()) as send_panel: + await reconcile_active_temp_channels(bot) + + send_panel.assert_not_called() + bot.storage.set_panel_message_id.assert_not_called() + # The room itself is still tracked; only the backfill is deferred. + assert bot.active_temp_channel_ids == {500} + + +async def test_reconcile_does_not_backfill_when_panel_already_set(): + row = _row(panel_message_id="777") + guild = make_guild(1) + owner = make_member(42, guild) + guild.get_member = Mock(return_value=owner) + channel = make_voice_channel(500, guild, members=[owner]) + guild.get_channel = Mock(return_value=channel) + bot = _make_bot(guild=guild) + bot.storage.list_active_temp_channels = AsyncMock(return_value=[row]) + + with patch("services.temp_channels.send_room_panel", new=AsyncMock()) as send_panel: + await reconcile_active_temp_channels(bot) + + send_panel.assert_not_called() + bot.storage.set_panel_message_id.assert_not_called() + + +# --- refcounted user_creation_locks under concurrency ---------------------- + + +async def test_create_temp_room_lock_evicted_after_concurrent_calls(): + guild = make_guild(1) + member = make_member(7, guild) + lobby = make_voice_channel(100, guild, category=None) + + created_channels = [] + + async def _create_voice_channel(**kwargs): + channel = make_voice_channel(200 + len(created_channels), guild) + created_channels.append(channel) + return channel + + guild.create_voice_channel = AsyncMock(side_effect=_create_voice_channel) + + storage = types.SimpleNamespace( + get_active_temp_channel_by_owner=AsyncMock(return_value=None), + get_guild_config=AsyncMock(return_value=None), + create_active_temp_channel=AsyncMock(), + delete_active_temp_channel=AsyncMock(), + set_panel_message_id=AsyncMock(), + ) + bot = types.SimpleNamespace( + storage=storage, + active_temp_channel_ids=set(), + user_creation_locks={}, + get_guild=Mock(return_value=guild), + fetch_channel=AsyncMock(return_value=None), + ) + + profile = { + "id": "profile-1", + "target_category_id": None, + "default_user_limit": None, + "temp_name_template": None, + } + + with patch( + "services.temp_channels.send_room_panel", + new=AsyncMock(return_value=make_message(message_id=1)), + ), patch("services.temp_channels.notify_duplicate_room", new=AsyncMock()): + await asyncio.gather( + *(create_temp_room(bot, member, lobby, profile) for _ in range(5)) + ) + + assert bot.user_creation_locks == {}