252 lines
9.3 KiB
Python
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()
|