Expanded integration coverage and enforced test categories.
This commit is contained in:
@@ -0,0 +1,251 @@
|
||||
# Copyright 2026 Logan Fick
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
"""Simcord integration tests for Crabstero bot lifecycle behavior."""
|
||||
|
||||
from typing import TYPE_CHECKING, Any, cast
|
||||
|
||||
import discord
|
||||
import pytest
|
||||
from discord import app_commands
|
||||
from simcord import run
|
||||
|
||||
from crabstero import metrics
|
||||
from crabstero.bot import Crabstero, TrackedModal, TrackedView
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
from simcord import Env
|
||||
|
||||
from tests.integration.discord.conftest import (
|
||||
MakeSimcordMemberChannel,
|
||||
SimcordMemberChannel,
|
||||
)
|
||||
|
||||
|
||||
def _counter_value(counter: Any, **labels: str) -> float:
|
||||
"""Return the current value for a labelled Prometheus counter."""
|
||||
return float(counter.labels(**labels)._value.get())
|
||||
|
||||
|
||||
class TestSetupHook:
|
||||
"""Bot startup wires Discord cogs and slash commands under Simcord."""
|
||||
|
||||
def test_db_before_setup_raises(self, tmp_path: Path) -> None:
|
||||
"""The database property is unavailable before setup_hook runs."""
|
||||
bot = Crabstero(str(tmp_path / "not-started.db"))
|
||||
|
||||
with pytest.raises(RuntimeError, match="Database is not initialized"):
|
||||
_ = bot.db
|
||||
|
||||
async def test_loads_cogs_and_syncs_commands(
|
||||
self,
|
||||
simcord_env: Env,
|
||||
crabstero_bot: Crabstero,
|
||||
) -> None:
|
||||
"""setup_hook loads expected cogs and syncs app commands."""
|
||||
assert set(crabstero_bot.cogs) == {
|
||||
"InteractionCog",
|
||||
"MessageCog",
|
||||
"ServerEventsCog",
|
||||
}
|
||||
|
||||
commands = simcord_env.backend.commands[None]
|
||||
assert {name for name, _ in commands} == {"forgetme", "pingme"}
|
||||
|
||||
application_id = simcord_env.backend.application_id
|
||||
http_routes = [f"{method} {path}" for method, path, _ in simcord_env.http_log]
|
||||
assert f"GET /applications/{application_id}/commands" in http_routes
|
||||
assert f"PUT /applications/{application_id}/commands" in http_routes
|
||||
|
||||
async def test_metrics_server_lifecycle_starts_and_stops(
|
||||
self,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""A configured metrics server is started and stopped with the bot."""
|
||||
bot = Crabstero(
|
||||
str(tmp_path / "metrics.db"),
|
||||
metrics_address=metrics.TcpMetricsAddress("127.0.0.1", 0),
|
||||
)
|
||||
try:
|
||||
async with run(bot):
|
||||
assert bot._metrics_server is not None
|
||||
assert bot._metrics_server.port > 0
|
||||
finally:
|
||||
bot.ws = None # type: ignore[assignment]
|
||||
await bot.close()
|
||||
|
||||
|
||||
class TestMetrics:
|
||||
"""Bot-level metrics are incremented by lifecycle error paths."""
|
||||
|
||||
def test_dispatch_increments_discord_event_metric(
|
||||
self,
|
||||
crabstero_bot: Crabstero,
|
||||
) -> None:
|
||||
"""Dispatch increments the labelled Discord event counter."""
|
||||
event = "codex_lifecycle_metric"
|
||||
before = _counter_value(metrics.DISCORD_EVENTS, event=event)
|
||||
|
||||
crabstero_bot.dispatch(event)
|
||||
|
||||
assert _counter_value(metrics.DISCORD_EVENTS, event=event) == before + 1
|
||||
|
||||
async def test_on_error_increments_event_error_metric(
|
||||
self,
|
||||
crabstero_bot: Crabstero,
|
||||
) -> None:
|
||||
"""Unhandled event listener failures are counted by event name."""
|
||||
source = "on_message"
|
||||
before = _counter_value(metrics.ERRORS, source=source)
|
||||
|
||||
await crabstero_bot.on_error(source)
|
||||
|
||||
assert _counter_value(metrics.ERRORS, source=source) == before + 1
|
||||
|
||||
async def test_tracked_view_and_modal_errors_increment_metrics(self) -> None:
|
||||
"""Tracked UI error handlers increment their error counters."""
|
||||
view_before = _counter_value(metrics.ERRORS, source="view")
|
||||
modal_before = _counter_value(metrics.ERRORS, source="modal")
|
||||
interaction = cast("discord.Interaction", object())
|
||||
button = cast("discord.ui.Item[TrackedView]", discord.ui.Button(label="Run"))
|
||||
|
||||
await TrackedView().on_error(interaction, RuntimeError("view failed"), button)
|
||||
await TrackedModal(title="Tracked").on_error(
|
||||
interaction,
|
||||
RuntimeError("modal failed"),
|
||||
)
|
||||
|
||||
assert _counter_value(metrics.ERRORS, source="view") == view_before + 1
|
||||
assert _counter_value(metrics.ERRORS, source="modal") == modal_before + 1
|
||||
|
||||
|
||||
class TestCommandErrors:
|
||||
"""Unhandled app command errors produce an ephemeral fallback response."""
|
||||
|
||||
async def test_app_command_error_sends_ephemeral_fallback(
|
||||
self,
|
||||
simcord_member_channel: SimcordMemberChannel,
|
||||
crabstero_bot: Crabstero,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""A failing slash command is captured and answered by tree.on_error."""
|
||||
member = simcord_member_channel.member
|
||||
channel = simcord_member_channel.channel
|
||||
|
||||
async def raise_on_set_flag(
|
||||
_entity_type: str,
|
||||
_entity_id: str,
|
||||
_flag_name: str,
|
||||
) -> None:
|
||||
raise RuntimeError("database unavailable")
|
||||
|
||||
monkeypatch.setattr(crabstero_bot.db, "set_flag", raise_on_set_flag)
|
||||
errors_before = _counter_value(metrics.ERRORS, source="command")
|
||||
|
||||
result = await member.slash(channel, "pingme")
|
||||
|
||||
assert result.response is not None
|
||||
assert result.response.ephemeral is True
|
||||
assert result.response.content == (
|
||||
"I encountered an error while processing this command."
|
||||
" Please try again later."
|
||||
)
|
||||
assert _counter_value(metrics.ERRORS, source="command") == errors_before + 1
|
||||
|
||||
async def test_app_command_error_mentions_developer_when_metrics_are_enabled(
|
||||
self,
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
make_simcord_member_channel: MakeSimcordMemberChannel,
|
||||
) -> None:
|
||||
"""Configured metrics switch command errors to the notified-developer copy."""
|
||||
bot = Crabstero(
|
||||
str(tmp_path / "metrics-command-errors.db"),
|
||||
metrics_address=metrics.TcpMetricsAddress("127.0.0.1", 0),
|
||||
)
|
||||
try:
|
||||
async with run(bot) as env:
|
||||
context = await make_simcord_member_channel(env)
|
||||
member = context.member
|
||||
channel = context.channel
|
||||
|
||||
async def raise_on_set_flag(
|
||||
_entity_type: str,
|
||||
_entity_id: str,
|
||||
_flag_name: str,
|
||||
) -> None:
|
||||
raise RuntimeError("database unavailable")
|
||||
|
||||
monkeypatch.setattr(bot.db, "set_flag", raise_on_set_flag)
|
||||
errors_before = _counter_value(metrics.ERRORS, source="command")
|
||||
|
||||
result = await member.slash(channel, "pingme")
|
||||
|
||||
assert result.response is not None
|
||||
assert result.response.ephemeral is True
|
||||
assert result.response.content == (
|
||||
"I encountered an error while processing this command."
|
||||
" The developer has been notified,"
|
||||
" please try again later."
|
||||
)
|
||||
assert (
|
||||
_counter_value(metrics.ERRORS, source="command")
|
||||
== errors_before + 1
|
||||
)
|
||||
finally:
|
||||
bot.ws = None # type: ignore[assignment]
|
||||
await bot.close()
|
||||
|
||||
async def test_app_command_error_after_defer_sends_ephemeral_followup(
|
||||
self,
|
||||
tmp_path: Path,
|
||||
make_simcord_member_channel: MakeSimcordMemberChannel,
|
||||
) -> None:
|
||||
"""A command that has already acknowledged uses a followup fallback."""
|
||||
bot = Crabstero(str(tmp_path / "deferred-command-errors.db"))
|
||||
|
||||
@app_commands.command(
|
||||
name="deferboom",
|
||||
description="Fail after acknowledging the interaction.",
|
||||
)
|
||||
async def deferboom(interaction: discord.Interaction) -> None:
|
||||
await interaction.response.defer(ephemeral=True)
|
||||
raise RuntimeError("deferred command failed")
|
||||
|
||||
bot.tree.add_command(deferboom)
|
||||
try:
|
||||
async with run(bot) as env:
|
||||
context = await make_simcord_member_channel(env)
|
||||
errors_before = _counter_value(metrics.ERRORS, source="command")
|
||||
|
||||
result = await context.member.slash(context.channel, "deferboom")
|
||||
|
||||
assert result.deferred is True
|
||||
assert result.response is None
|
||||
assert len(result.followups) == 1
|
||||
followup = result.followups[0]
|
||||
assert followup.ephemeral is True
|
||||
assert followup.content == (
|
||||
"I encountered an error while processing this command."
|
||||
" Please try again later."
|
||||
)
|
||||
assert (
|
||||
_counter_value(metrics.ERRORS, source="command")
|
||||
== errors_before + 1
|
||||
)
|
||||
finally:
|
||||
bot.ws = None # type: ignore[assignment]
|
||||
await bot.close()
|
||||
Reference in New Issue
Block a user