Files
Crabstero/tests/integration/discord/test_bot_lifecycle.py
T
LogalDeveloper 5bbb0bbde5
Audit / Dependencies (push) Successful in 9s
CI / Formatting (push) Successful in 5s
CI / Linting (push) Successful in 4s
CI / Tests (push) Successful in 1m7s
CI / Type Checking (push) Successful in 12s
CI / Spelling (push) Successful in 8s
Expanded integration coverage and enforced test categories.
2026-06-18 16:10:40 -04:00

252 lines
9.3 KiB
Python

# 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()