Expanded integration coverage and enforced test categories.
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

This commit is contained in:
2026-06-18 16:10:40 -04:00
parent 49062159f9
commit 5bbb0bbde5
22 changed files with 2433 additions and 550 deletions
+7 -1
View File
@@ -30,6 +30,7 @@ dev = [
"pytest-asyncio>=1.4.0",
"pytest-cov>=7.1.0",
"ruff>=0.15.16",
"simcord==1.0.1",
]
[tool.uv]
@@ -59,13 +60,17 @@ mypy_path = "$MYPY_CONFIG_FILE_DIR/src"
[tool.pytest.ini_options]
asyncio_mode = "auto"
asyncio_default_fixture_loop_scope = "function"
asyncio_default_test_loop_scope = "function"
testpaths = ["tests"]
pythonpath = ["src"]
addopts = [
"--import-mode=importlib",
"--strict-config",
"--strict-markers",
]
markers = [
"unit: fast, deterministic tests that do not cross real external boundaries",
"integration: tests that exercise real local boundaries such as SQLite, sockets, CLI wiring, Discord simulation, or service lifecycle",
]
xfail_strict = true
[tool.ruff]
@@ -111,3 +116,4 @@ omit = [
[tool.coverage.report]
show_missing = true
skip_empty = true
fail_under = 95
+27 -12
View File
@@ -12,21 +12,36 @@
# See the License for the specific language governing permissions and
# limitations under the License.
"""Shared pytest fixtures for the test suite."""
from typing import TYPE_CHECKING
"""Shared pytest hooks for the test suite."""
import pytest
from crabstero.database import Database
if TYPE_CHECKING:
from collections.abc import AsyncGenerator
@pytest.hookimpl(tryfirst=True)
def pytest_collection_modifyitems(
config: pytest.Config,
items: list[pytest.Item],
) -> None:
"""Apply and enforce primary test category markers by directory."""
tests_root = config.rootpath / "tests"
category_roots = (
(tests_root / "unit", pytest.mark.unit),
(tests_root / "integration", pytest.mark.integration),
)
uncategorized: list[str] = []
for item in items:
for category_root, marker in category_roots:
if item.path.is_relative_to(category_root):
item.add_marker(marker)
break
else:
uncategorized.append(str(item.path.relative_to(config.rootpath)))
@pytest.fixture
async def db() -> AsyncGenerator[Database]:
"""Yield a Database backed by an in-memory SQLite database."""
database = await Database.connect(":memory:")
yield database
await database.close()
if uncategorized:
formatted_paths = "\n".join(f" - {path}" for path in uncategorized)
msg = (
"Tests must live under tests/unit or tests/integration.\n"
f"Uncategorized tests:\n{formatted_paths}"
)
raise pytest.UsageError(msg)
+15
View File
@@ -0,0 +1,15 @@
# 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.
"""CLI integration tests for Crabstero."""
@@ -0,0 +1,89 @@
# 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.
"""Integration tests for systemd notification socket behavior."""
import logging
import os
import socket
import sys
from typing import TYPE_CHECKING
import pytest
from crabstero.cli import _sd_notify
if TYPE_CHECKING:
from pathlib import Path
pytestmark = pytest.mark.skipif(
os.name != "posix" or not hasattr(socket, "AF_UNIX"),
reason="systemd notification sockets require Unix-domain socket support",
)
class TestSystemdNotifySocket:
"""Systemd notification helper behavior against real Unix-domain sockets."""
def test_sd_notify_sends_datagram(
self,
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""_sd_notify sends the payload to NOTIFY_SOCKET."""
socket_path = tmp_path / "notify.sock"
with socket.socket(socket.AF_UNIX, socket.SOCK_DGRAM) as server:
server.bind(str(socket_path))
server.settimeout(1)
monkeypatch.setenv("NOTIFY_SOCKET", str(socket_path))
_sd_notify("READY=1")
assert server.recv(1024) == b"READY=1"
def test_sd_notify_ignores_socket_errors(
self,
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
caplog: pytest.LogCaptureFixture,
) -> None:
"""_sd_notify logs and suppresses notification socket send failures."""
monkeypatch.setenv("NOTIFY_SOCKET", str(tmp_path / "missing.sock"))
with caplog.at_level(logging.DEBUG, logger="crabstero"):
_sd_notify("READY=1")
assert "Could not send systemd notification" in caplog.text
@pytest.mark.skipif(
sys.platform != "linux",
reason="abstract Unix sockets are Linux-specific",
)
def test_sd_notify_sends_to_abstract_socket(
self,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""@-prefixed NOTIFY_SOCKET values target Linux abstract sockets."""
socket_name = f"crabstero-notify-{os.getpid()}"
with socket.socket(socket.AF_UNIX, socket.SOCK_DGRAM) as server:
server.bind(f"\0{socket_name}")
server.settimeout(1)
monkeypatch.setenv("NOTIFY_SOCKET", f"@{socket_name}")
_sd_notify("READY=1")
assert server.recv(1024) == b"READY=1"
+15
View File
@@ -0,0 +1,15 @@
# 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.
"""Database integration tests for Crabstero."""
+35
View File
@@ -0,0 +1,35 @@
# 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.
"""Fixtures used only by database-bound integration tests."""
from typing import TYPE_CHECKING
import pytest
from crabstero.database import Database
if TYPE_CHECKING:
from collections.abc import AsyncGenerator
from pathlib import Path
@pytest.fixture
async def db(tmp_path: Path) -> AsyncGenerator[Database]:
"""Yield an isolated file-backed database for SQLite integration tests."""
database = await Database.connect(str(tmp_path / "crabstero.db"))
try:
yield database
finally:
await database.close()
@@ -12,7 +12,7 @@
# See the License for the specific language governing permissions and
# limitations under the License.
"""Unit tests for the Database class.
"""Integration tests for the Database class.
Tests cover Database.connect (pragmas, schema), markov start word and
transition CRUD, image storage, flag CRUD, and channel ingestion tracking.
@@ -509,21 +509,21 @@ class TestTransactionRollback:
async def test_error_rolls_back_insert(self, db: Database) -> None:
"""An error during a transaction prevents partial data from persisting."""
with pytest.raises(RuntimeError, match="simulated"):
await self._insert_and_fail(db)
assert await db.get_random_start_word(1) is None
@staticmethod
async def _insert_and_fail(db: Database) -> None:
async with db._transaction():
await db._connection.execute(
"INSERT INTO markov_start_words"
" (channel_id, user_id, word)"
" VALUES (?, ?, ?)",
(1, 100, "should_not_persist"),
)
msg = "simulated failure"
raise RuntimeError(msg)
async def insert_and_fail() -> None:
async with db._transaction():
await db._connection.execute(
"INSERT INTO markov_start_words"
" (channel_id, user_id, word)"
" VALUES (?, ?, ?)",
(1, 100, "should_not_persist"),
)
msg = "simulated failure"
raise RuntimeError(msg)
with pytest.raises(RuntimeError, match="simulated"):
await insert_and_fail()
assert await db.get_random_start_word(1) is None
class TestForgetUser:
@@ -629,13 +629,17 @@ class TestWriteDurability:
"""Data written via add_markov_data is durable after close/reopen."""
db_path = str(tmp_path / "durability.db")
db = await Database.connect(db_path)
await db.add_markov_data(
[StartWord(1, 100, "Hello")],
[Transition(1, 100, "Hello", "world.")],
)
await db.close()
try:
await db.add_markov_data(
[StartWord(1, 100, "Hello")],
[Transition(1, 100, "Hello", "world.")],
)
finally:
await db.close()
db2 = await Database.connect(db_path)
assert await db2.get_random_start_word(1) == "Hello"
assert await db2.get_random_next_word(1, "Hello") == "world."
await db2.close()
try:
assert await db2.get_random_start_word(1) == "Hello"
assert await db2.get_random_next_word(1, "Hello") == "world."
finally:
await db2.close()
@@ -14,8 +14,10 @@
"""Integration tests for the full ingest → uningest cycle."""
from collections.abc import Awaitable, Callable
from typing import TYPE_CHECKING
import aiosqlite
import pytest
from crabstero.cache import CachedMessage, IngestCache
@@ -24,18 +26,31 @@ from crabstero.markov import ingest, uningest
from crabstero.messages import uningest_message
if TYPE_CHECKING:
import aiosqlite
from crabstero.database import Database
type SnapshotRows = Callable[["Database"], Awaitable[dict[str, list[aiosqlite.Row]]]]
async def _snapshot(db: Database) -> dict[str, list[aiosqlite.Row]]:
"""Return sorted rows from all Markov-related tables."""
tables: dict[str, list[aiosqlite.Row]] = {}
for table in ("markov_start_words", "markov_transitions", "channel_images"):
async with db._connection.execute(f"SELECT * FROM {table}") as cursor: # noqa: S608
tables[table] = sorted(await cursor.fetchall())
return tables
@pytest.fixture
def snapshot_rows() -> SnapshotRows:
"""Return a snapshot reader for Markov and image tables."""
async def read(db: Database) -> dict[str, list[aiosqlite.Row]]:
tables: dict[str, list[aiosqlite.Row]] = {}
for table in ("markov_start_words", "markov_transitions", "channel_images"):
async with db._connection.execute(
f"SELECT * FROM {table}", # noqa: S608
) as cursor:
tables[table] = sorted(await cursor.fetchall())
return tables
return read
@pytest.fixture
def ingest_cache() -> IngestCache:
"""Return an empty ingest cache for uningest-message tests."""
return IngestCache()
class TestIngestUningestCycle:
@@ -114,12 +129,17 @@ class TestUningestRestoresState:
),
],
)
async def test_uningest_restores_empty_db(self, db: Database, text: str) -> None:
async def test_uningest_restores_empty_db(
self,
db: Database,
text: str,
snapshot_rows: SnapshotRows,
) -> None:
"""Ingest then uningest on an empty database leaves all tables empty."""
before = await _snapshot(db)
before = await snapshot_rows(db)
await ingest(db, 1, 100, text)
await uningest(db, 1, 100, text)
after = await _snapshot(db)
after = await snapshot_rows(db)
assert after == before
@pytest.mark.parametrize(
@@ -136,26 +156,26 @@ class TestUningestRestoresState:
self,
db: Database,
text: str,
snapshot_rows: SnapshotRows,
) -> None:
"""Ingest then uningest preserves unrelated pre-existing data exactly."""
await ingest(db, 99, 200, "Pre-existing data stays safe.")
await db.add_images([ChannelImage(99, 200, "https://example.com/existing.png")])
before = await _snapshot(db)
before = await snapshot_rows(db)
await ingest(db, 1, 100, text)
await uningest(db, 1, 100, text)
after = await _snapshot(db)
after = await snapshot_rows(db)
assert after == before
class TestUningestMessage:
"""Orchestrated uningest via cache lookup and database reversal."""
async def test_content_only(self, db: Database) -> None:
async def test_content_only(self, db: Database, ingest_cache: IngestCache) -> None:
"""Uningest reverses a content-only message via the cache."""
await ingest(db, 1, 100, "Hello beautiful world.")
cache = IngestCache()
cache.put(
ingest_cache.put(
555,
CachedMessage(
channel_id=1,
@@ -166,17 +186,16 @@ class TestUningestMessage:
),
)
await uningest_message(db, cache, 555)
await uningest_message(db, ingest_cache, 555)
assert await db.get_random_start_word(1) is None
assert await db.get_random_next_word(1, "Hello") is None
async def test_embeds_only(self, db: Database) -> None:
async def test_embeds_only(self, db: Database, ingest_cache: IngestCache) -> None:
"""Uningest reverses embed text ingestion."""
await ingest(db, 1, 100, "Embed title here.")
await ingest(db, 1, 100, "Embed description here.")
cache = IngestCache()
cache.put(
ingest_cache.put(
556,
CachedMessage(
channel_id=1,
@@ -187,17 +206,20 @@ class TestUningestMessage:
),
)
await uningest_message(db, cache, 556)
await uningest_message(db, ingest_cache, 556)
assert await db.get_random_start_word(1) is None
async def test_content_with_embeds_and_images(self, db: Database) -> None:
async def test_content_with_embeds_and_images(
self,
db: Database,
ingest_cache: IngestCache,
) -> None:
"""Uningest reverses content, embed text, and image data together."""
await ingest(db, 1, 100, "Body text here.")
await ingest(db, 1, 100, "Embed title.")
await db.add_images([ChannelImage(1, 100, "https://example.com/img.png")])
cache = IngestCache()
cache.put(
ingest_cache.put(
557,
CachedMessage(
channel_id=1,
@@ -208,28 +230,35 @@ class TestUningestMessage:
),
)
await uningest_message(db, cache, 557)
await uningest_message(db, ingest_cache, 557)
assert await db.get_random_start_word(1) is None
assert await db.get_random_image(1) is None
async def test_cache_miss_is_noop(self, db: Database) -> None:
async def test_cache_miss_is_noop(
self,
db: Database,
ingest_cache: IngestCache,
snapshot_rows: SnapshotRows,
) -> None:
"""A message not in the cache leaves the database unchanged."""
await ingest(db, 1, 100, "Keep this data.")
cache = IngestCache()
before = await _snapshot(db)
before = await snapshot_rows(db)
await uningest_message(db, cache, 999)
await uningest_message(db, ingest_cache, 999)
after = await _snapshot(db)
after = await snapshot_rows(db)
assert after == before
async def test_preserves_other_messages(self, db: Database) -> None:
async def test_preserves_other_messages(
self,
db: Database,
ingest_cache: IngestCache,
) -> None:
"""Uningesting one message leaves another message's data intact."""
await ingest(db, 1, 100, "First message.")
await ingest(db, 1, 100, "Second message.")
cache = IngestCache()
cache.put(
ingest_cache.put(
601,
CachedMessage(
channel_id=1,
@@ -240,16 +269,19 @@ class TestUningestMessage:
),
)
await uningest_message(db, cache, 601)
await uningest_message(db, ingest_cache, 601)
assert await db.get_random_start_word(1) == "Second"
assert await db.get_random_next_word(1, "Second") == "message."
async def test_pops_entry_from_cache(self, db: Database) -> None:
async def test_pops_entry_from_cache(
self,
db: Database,
ingest_cache: IngestCache,
) -> None:
"""The cache entry is consumed after uningest."""
await ingest(db, 1, 100, "Hello world.")
cache = IngestCache()
cache.put(
ingest_cache.put(
602,
CachedMessage(
channel_id=1,
@@ -260,6 +292,6 @@ class TestUningestMessage:
),
)
await uningest_message(db, cache, 602)
await uningest_message(db, ingest_cache, 602)
assert cache.pop(602) is None
assert ingest_cache.pop(602) is None
+15
View File
@@ -0,0 +1,15 @@
# 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.
"""Discord-boundary integration tests."""
+129
View File
@@ -0,0 +1,129 @@
# 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.
"""Fixtures used only by Simcord-backed Discord integration tests."""
from collections.abc import Awaitable, Callable
from dataclasses import dataclass
from typing import TYPE_CHECKING
import pytest
from crabstero.bot import Crabstero
from crabstero.database import Database
if TYPE_CHECKING:
from collections.abc import AsyncGenerator
from pathlib import Path
from simcord import ChannelHandle, Env, GuildHandle, MemberActor
type StartWordsForChannel = Callable[[Database, int], Awaitable[list[str]]]
type MakeSimcordTextChannel = Callable[["Env"], Awaitable["SimcordTextChannel"]]
type MakeSimcordMemberChannel = Callable[["Env"], Awaitable["SimcordMemberChannel"]]
@dataclass(frozen=True, slots=True)
class SimcordTextChannel:
"""Guild and text channel created inside a running Simcord environment."""
guild: GuildHandle
channel: ChannelHandle
@dataclass(frozen=True, slots=True)
class SimcordMemberChannel:
"""Guild, text channel, and human member for Discord flow tests."""
guild: GuildHandle
member: MemberActor
channel: ChannelHandle
@pytest.fixture
async def crabstero_bot(tmp_path: Path) -> AsyncGenerator[Crabstero]:
"""Yield the Crabstero bot instance inspected by Discord integration tests."""
bot = Crabstero(str(tmp_path / "crabstero.db"))
try:
yield bot
finally:
bot.ws = None # type: ignore[assignment]
await bot.close()
@pytest.fixture
def simcord_bot(crabstero_bot: Crabstero) -> Crabstero:
"""Expose Crabstero under the fixture name required by Simcord."""
return crabstero_bot
@pytest.fixture
def make_simcord_text_channel() -> MakeSimcordTextChannel:
"""Return a factory for guild text channels in any Simcord environment."""
async def make(env: Env) -> SimcordTextChannel:
guild = env.create_guild()
await env.settle()
channel = guild.create_text_channel("general")
await env.settle()
return SimcordTextChannel(guild, channel)
return make
@pytest.fixture
async def simcord_text_channel(
simcord_env: Env,
make_simcord_text_channel: MakeSimcordTextChannel,
) -> SimcordTextChannel:
"""Create one guild text channel in the default Simcord environment."""
return await make_simcord_text_channel(simcord_env)
@pytest.fixture
def make_simcord_member_channel() -> MakeSimcordMemberChannel:
"""Return a factory for guild/member/channel triples in any Simcord env."""
async def make(env: Env) -> SimcordMemberChannel:
guild = env.create_guild()
await env.settle()
channel = guild.create_text_channel("general")
member = guild.add_member(env.create_user("Ada"))
await env.settle()
return SimcordMemberChannel(guild, member, channel)
return make
@pytest.fixture
async def simcord_member_channel(
simcord_env: Env,
make_simcord_member_channel: MakeSimcordMemberChannel,
) -> SimcordMemberChannel:
"""Create one guild text channel and human member in the default Simcord env."""
return await make_simcord_member_channel(simcord_env)
@pytest.fixture
def start_words_for_channel() -> StartWordsForChannel:
"""Return a reader for persisted Markov start words in one Discord channel."""
async def read(db: Database, channel_id: int) -> list[str]:
async with db._connection.execute(
"SELECT word FROM markov_start_words WHERE channel_id = ? ORDER BY word",
(channel_id,),
) as cursor:
return [str(row[0]) for row in await cursor.fetchall()]
return read
@@ -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()
@@ -0,0 +1,276 @@
# 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 slash commands."""
from collections.abc import Awaitable, Callable
from typing import TYPE_CHECKING
import pytest
from crabstero.database import ChannelImage, StartWord, Transition
from crabstero.flags import Flag
if TYPE_CHECKING:
from simcord import Env
from crabstero.bot import Crabstero
from crabstero.database import Database
from tests.integration.discord.conftest import SimcordMemberChannel
type SeedForgetmeData = Callable[["Database", int], Awaitable[None]]
type ForgetmeRowsForUser = Callable[["Database", int], Awaitable[tuple[int, int, int]]]
@pytest.fixture
def seed_forgetme_data() -> SeedForgetmeData:
"""Return a seeder for user-owned and unrelated /forgetme database rows."""
async def seed(db: Database, user_id: int) -> None:
await db.add_markov_data(
[
StartWord(10, user_id, "delete"),
StartWord(10, 999, "keep"),
],
[
Transition(10, user_id, "delete", "me."),
Transition(10, 999, "keep", "me."),
],
)
await db.add_images(
[
ChannelImage(10, user_id, "https://example.com/delete.png"),
ChannelImage(10, 999, "https://example.com/keep.png"),
],
)
return seed
@pytest.fixture
def forgetme_rows_for_user() -> ForgetmeRowsForUser:
"""Return a reader for Markov/image row counts owned by one user."""
async def read(db: Database, user_id: int) -> tuple[int, int, int]:
async with db._connection.execute(
"SELECT COUNT(*) FROM markov_start_words WHERE user_id = ?",
(user_id,),
) as cursor:
row = await cursor.fetchone()
assert row is not None
start_words = row[0]
async with db._connection.execute(
"SELECT COUNT(*) FROM markov_transitions WHERE user_id = ?",
(user_id,),
) as cursor:
row = await cursor.fetchone()
assert row is not None
transitions = row[0]
async with db._connection.execute(
"SELECT COUNT(*) FROM channel_images WHERE user_id = ?",
(user_id,),
) as cursor:
row = await cursor.fetchone()
assert row is not None
images = row[0]
return int(start_words), int(transitions), int(images)
return read
class TestPingMe:
"""The /pingme command toggles persisted user opt-in state."""
async def test_first_call_opts_in(
self,
simcord_member_channel: SimcordMemberChannel,
crabstero_bot: Crabstero,
) -> None:
"""The first /pingme call sets allowPings and responds ephemerally."""
member = simcord_member_channel.member
channel = simcord_member_channel.channel
result = await member.slash(channel, "pingme")
assert result.response is not None
assert result.response.ephemeral is True
assert "I will now ping you" in result.response.content
assert await crabstero_bot.db.is_flag_set(
"user",
str(member.id),
Flag.ALLOW_PINGS,
)
async def test_second_call_opts_out(
self,
simcord_member_channel: SimcordMemberChannel,
crabstero_bot: Crabstero,
) -> None:
"""The second /pingme call clears allowPings and responds ephemerally."""
member = simcord_member_channel.member
channel = simcord_member_channel.channel
await member.slash(channel, "pingme")
result = await member.slash(channel, "pingme")
assert result.response is not None
assert result.response.ephemeral is True
assert "I will no longer ping you" in result.response.content
assert not await crabstero_bot.db.is_flag_set(
"user",
str(member.id),
Flag.ALLOW_PINGS,
)
class TestForgetMe:
"""The /forgetme command confirms, cancels, and times out hermetically."""
async def test_initial_response_has_confirmation_buttons(
self,
simcord_member_channel: SimcordMemberChannel,
) -> None:
"""The first /forgetme response is ephemeral and asks for confirmation."""
member = simcord_member_channel.member
channel = simcord_member_channel.channel
result = await member.slash(channel, "forgetme")
assert result.response is not None
assert result.response.ephemeral is True
assert "Would you like to proceed?" in result.response.content
labels = [
component["label"]
for row in result.response.components
for component in row["components"]
]
assert labels == ["Confirm", "Cancel"]
async def test_confirm_deletes_user_data_and_sets_no_ingest(
self,
simcord_member_channel: SimcordMemberChannel,
crabstero_bot: Crabstero,
seed_forgetme_data: SeedForgetmeData,
forgetme_rows_for_user: ForgetmeRowsForUser,
) -> None:
"""Confirming /forgetme deletes user data and persists noIngest."""
member = simcord_member_channel.member
channel = simcord_member_channel.channel
await seed_forgetme_data(crabstero_bot.db, member.id)
await crabstero_bot.db.set_flag("user", str(member.id), Flag.ALLOW_PINGS)
await crabstero_bot.db.set_flag("user", str(member.id), Flag.NO_REPLY)
prompt = (await member.slash(channel, "forgetme")).response
assert prompt is not None
result = await member.click(prompt, label="Confirm")
assert result.response is not None
assert result.response.ephemeral is True
assert "I have deleted your data" in result.response.content
assert result.response.components == []
assert await forgetme_rows_for_user(crabstero_bot.db, member.id) == (0, 0, 0)
assert await forgetme_rows_for_user(crabstero_bot.db, 999) == (1, 1, 1)
assert await crabstero_bot.db.is_flag_set(
"user",
str(member.id),
Flag.NO_INGEST,
)
assert not await crabstero_bot.db.is_flag_set(
"user",
str(member.id),
Flag.ALLOW_PINGS,
)
assert not await crabstero_bot.db.is_flag_set(
"user",
str(member.id),
Flag.NO_REPLY,
)
async def test_cancel_leaves_user_data_and_flags(
self,
simcord_member_channel: SimcordMemberChannel,
crabstero_bot: Crabstero,
seed_forgetme_data: SeedForgetmeData,
forgetme_rows_for_user: ForgetmeRowsForUser,
) -> None:
"""Cancelling /forgetme leaves data and user flags unchanged."""
member = simcord_member_channel.member
channel = simcord_member_channel.channel
await seed_forgetme_data(crabstero_bot.db, member.id)
await crabstero_bot.db.set_flag("user", str(member.id), Flag.ALLOW_PINGS)
await crabstero_bot.db.set_flag("user", str(member.id), Flag.NO_REPLY)
prompt = (await member.slash(channel, "forgetme")).response
assert prompt is not None
result = await member.click(prompt, label="Cancel")
assert result.response is not None
assert result.response.ephemeral is True
assert result.response.content == (
"Action cancelled. I have not modified your data."
)
assert result.response.components == []
assert await forgetme_rows_for_user(crabstero_bot.db, member.id) == (1, 1, 1)
assert await crabstero_bot.db.is_flag_set(
"user",
str(member.id),
Flag.ALLOW_PINGS,
)
assert await crabstero_bot.db.is_flag_set(
"user",
str(member.id),
Flag.NO_REPLY,
)
assert not await crabstero_bot.db.is_flag_set(
"user",
str(member.id),
Flag.NO_INGEST,
)
async def test_already_forgotten_user_gets_terminal_response(
self,
simcord_member_channel: SimcordMemberChannel,
crabstero_bot: Crabstero,
) -> None:
"""A noIngest user does not get another confirmation view."""
member = simcord_member_channel.member
channel = simcord_member_channel.channel
await crabstero_bot.db.set_flag("user", str(member.id), Flag.NO_INGEST)
result = await member.slash(channel, "forgetme")
assert result.response is not None
assert result.response.ephemeral is True
assert result.response.content == (
"I have already removed your data and I am not using your messages."
)
assert result.response.components == []
async def test_confirmation_timeout_removes_view(
self,
simcord_env: Env,
simcord_member_channel: SimcordMemberChannel,
) -> None:
"""The /forgetme view timeout edits the original response without sleeping."""
member = simcord_member_channel.member
channel = simcord_member_channel.channel
prompt = (await member.slash(channel, "forgetme")).response
assert prompt is not None
await simcord_env.advance_time(181)
assert prompt.content == (
"This timed out. Run `/forgetme` again if you still want to."
)
assert prompt.components == []
+470
View File
@@ -0,0 +1,470 @@
# 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 Discord message events."""
from collections.abc import Awaitable, Callable
from typing import TYPE_CHECKING
import discord
import pytest
from simcord import run
from crabstero.bot import Crabstero
from crabstero.database import ChannelImage, StartWord, Transition
from crabstero.flags import EntityType, Flag
from crabstero.messages import ingest_message, reply_to_message
if TYPE_CHECKING:
from pathlib import Path
from simcord import Env
from crabstero.database import Database
from tests.integration.discord.conftest import (
MakeSimcordMemberChannel,
SimcordMemberChannel,
StartWordsForChannel,
)
type SeedReply = Callable[["Database", int], Awaitable[None]]
@pytest.fixture
def seed_reply() -> SeedReply:
"""Return a seeder for deterministic generated replies in one channel."""
async def seed(db: Database, channel_id: int) -> None:
await db.add_markov_data(
[StartWord(channel_id, 123, "Generated")],
[Transition(channel_id, 123, "Generated", "reply.")],
)
return seed
class TestMessageIngestion:
"""Normal Discord message flow populates the real database."""
async def test_normal_guild_user_message_is_ingested(
self,
simcord_member_channel: SimcordMemberChannel,
crabstero_bot: Crabstero,
start_words_for_channel: StartWordsForChannel,
) -> None:
"""A default guild text message adds Markov data."""
member = simcord_member_channel.member
channel = simcord_member_channel.channel
await member.send(channel, "Alpha beta.")
assert await start_words_for_channel(crabstero_bot.db, channel.id) == ["Alpha"]
assert (
await crabstero_bot.db.get_random_next_word(channel.id, "Alpha") == "beta."
)
async def test_bot_messages_are_ignored(
self,
simcord_env: Env,
simcord_member_channel: SimcordMemberChannel,
crabstero_bot: Crabstero,
start_words_for_channel: StartWordsForChannel,
) -> None:
"""A bot-authored gateway message is not ingested."""
channel = simcord_member_channel.channel
simcord_env.backend.create_message(
channel.id,
simcord_env.backend.bot_user.id,
"Ignore bot.",
)
await simcord_env.settle()
assert await start_words_for_channel(crabstero_bot.db, channel.id) == []
async def test_dm_messages_are_ignored(
self,
simcord_env: Env,
crabstero_bot: Crabstero,
start_words_for_channel: StartWordsForChannel,
) -> None:
"""DM messages are outside the guild channel types Crabstero handles."""
user = simcord_env.create_user("Ada")
await user.send_dm("Direct message.")
assert (
await start_words_for_channel(
crabstero_bot.db,
user.dm_channel.id,
)
== []
)
async def test_thread_messages_do_not_create_separate_chain(
self,
simcord_env: Env,
simcord_member_channel: SimcordMemberChannel,
crabstero_bot: Crabstero,
start_words_for_channel: StartWordsForChannel,
) -> None:
"""Messages inside threads are not ingested under the thread channel id."""
member = simcord_member_channel.member
channel = simcord_member_channel.channel
simcord_env.backend.create_thread(channel.id, "thread", member.id)
await simcord_env.settle()
thread = channel.threads[0]
await member.send(thread, "Thread only.")
assert await start_words_for_channel(crabstero_bot.db, thread.id) == []
assert await start_words_for_channel(crabstero_bot.db, channel.id) == []
async def test_embed_message_text_and_image_are_ingested(
self,
simcord_env: Env,
simcord_member_channel: SimcordMemberChannel,
crabstero_bot: Crabstero,
start_words_for_channel: StartWordsForChannel,
) -> None:
"""Embed titles, descriptions, and image URLs are ingested."""
member = simcord_member_channel.member
channel = simcord_member_channel.channel
simcord_env.backend.create_message(
channel.id,
member.id,
embeds=[
{
"title": "Title words.",
"description": "Description words.",
"image": {"url": "https://example.com/embed.png"},
},
],
)
await simcord_env.settle()
assert await start_words_for_channel(crabstero_bot.db, channel.id) == [
"Description",
"Title",
]
assert (
await crabstero_bot.db.get_random_image(channel.id)
== "https://example.com/embed.png"
)
async def test_empty_messages_are_not_ingested(
self,
simcord_member_channel: SimcordMemberChannel,
crabstero_bot: Crabstero,
start_words_for_channel: StartWordsForChannel,
) -> None:
"""A message with no content or embeds is ignored."""
member = simcord_member_channel.member
channel = simcord_member_channel.channel
await member.send(channel)
assert await start_words_for_channel(crabstero_bot.db, channel.id) == []
async def test_ingest_only_mode_ingests_without_replying(
self,
tmp_path: Path,
make_simcord_member_channel: MakeSimcordMemberChannel,
start_words_for_channel: StartWordsForChannel,
) -> None:
"""An ingest-only bot still ingests eligible messages and never replies."""
bot = Crabstero(str(tmp_path / "ingest-only.db"), ingest_only=True)
try:
async with run(bot) as env:
context = await make_simcord_member_channel(env)
member = context.member
channel = context.channel
await member.send(channel, "Ingest only.")
assert await start_words_for_channel(bot.db, channel.id) == ["Ingest"]
assert [message.content for message in channel.history()] == [
"Ingest only.",
]
finally:
bot.ws = None # type: ignore[assignment]
await bot.close()
class TestReplies:
"""Mentions produce replies through the real Discord message path."""
async def test_mentioning_bot_sends_seeded_reply(
self,
simcord_env: Env,
simcord_member_channel: SimcordMemberChannel,
crabstero_bot: Crabstero,
seed_reply: SeedReply,
) -> None:
"""A mention causes a deterministic Markov reply to be posted."""
member = simcord_member_channel.member
channel = simcord_member_channel.channel
await seed_reply(crabstero_bot.db, channel.id)
await member.send(channel, f"<@{simcord_env.backend.bot_user.id}> please reply")
history = channel.history()
assert [message.content for message in history] == [
f"<@{simcord_env.backend.bot_user.id}> please reply",
"Generated reply.",
]
assert history[-1].reference is not None
assert history[-1].author == simcord_env.bot.user
async def test_mention_reply_can_include_embed_and_image(
self,
simcord_env: Env,
simcord_member_channel: SimcordMemberChannel,
crabstero_bot: Crabstero,
monkeypatch: pytest.MonkeyPatch,
seed_reply: SeedReply,
) -> None:
"""The optional reply embed path uses generated text and stored images."""
member = simcord_member_channel.member
channel = simcord_member_channel.channel
await seed_reply(crabstero_bot.db, channel.id)
await crabstero_bot.db.add_images(
[ChannelImage(channel.id, 123, "https://example.com/reply.png")],
)
monkeypatch.setattr("crabstero.messages.secrets.randbelow", lambda _upper: 95)
await member.send(channel, f"<@{simcord_env.backend.bot_user.id}> please reply")
reply = channel.history()[-1]
assert reply.content == "Generated reply."
assert len(reply.embeds) == 1
assert reply.embeds[0].title == "Generated reply."
assert reply.embeds[0].description == "Generated reply."
assert reply.embeds[0].image.url == "https://example.com/reply.png"
async def test_mention_in_thread_uses_parent_channel_chain(
self,
simcord_env: Env,
simcord_member_channel: SimcordMemberChannel,
crabstero_bot: Crabstero,
seed_reply: SeedReply,
) -> None:
"""A thread mention generates from the parent channel's Markov chain."""
member = simcord_member_channel.member
channel = simcord_member_channel.channel
await seed_reply(crabstero_bot.db, channel.id)
simcord_env.backend.create_thread(channel.id, "thread", member.id)
await simcord_env.settle()
thread = channel.threads[0]
await member.send(thread, f"<@{simcord_env.backend.bot_user.id}> thread reply")
assert [message.content for message in thread.history()] == [
f"<@{simcord_env.backend.bot_user.id}> thread reply",
"Generated reply.",
]
async def test_allow_pings_flag_allows_generated_user_mentions(
self,
simcord_env: Env,
simcord_member_channel: SimcordMemberChannel,
crabstero_bot: Crabstero,
) -> None:
"""Generated mentions are allowed for users who opted in."""
member = simcord_member_channel.member
channel = simcord_member_channel.channel
target = simcord_env.create_user("Mentioned")
await crabstero_bot.db.add_markov_data(
[StartWord(channel.id, 123, "Hello")],
[Transition(channel.id, 123, "Hello", f"{target.mention}.")],
)
await crabstero_bot.db.set_flag(
EntityType.USER,
str(target.id),
Flag.ALLOW_PINGS,
)
await member.send(channel, f"<@{simcord_env.backend.bot_user.id}> please reply")
assert channel.history()[-1].content == f"Hello {target.mention}."
async def test_no_reply_flag_suppresses_reply(
self,
simcord_env: Env,
simcord_member_channel: SimcordMemberChannel,
crabstero_bot: Crabstero,
seed_reply: SeedReply,
) -> None:
"""The noReply flag prevents a mention response."""
member = simcord_member_channel.member
channel = simcord_member_channel.channel
await seed_reply(crabstero_bot.db, channel.id)
await crabstero_bot.db.set_flag(
EntityType.CHANNEL,
str(channel.id),
Flag.NO_REPLY,
)
await member.send(channel, f"<@{simcord_env.backend.bot_user.id}> please reply")
assert [message.content for message in channel.history()] == [
f"<@{simcord_env.backend.bot_user.id}> please reply",
]
async def test_missing_send_permission_suppresses_reply(
self,
simcord_env: Env,
crabstero_bot: Crabstero,
seed_reply: SeedReply,
) -> None:
"""A bot without send_messages permission does not reply."""
guild = simcord_env.create_guild()
bot_role = guild.roles[simcord_env.backend.bot_user.name]
channel = guild.create_text_channel(
"readonly",
overwrites={
bot_role: discord.PermissionOverwrite(send_messages=False),
},
)
member = guild.add_member(simcord_env.create_user("Ada"))
await simcord_env.settle()
await seed_reply(crabstero_bot.db, channel.id)
await member.send(channel, f"<@{simcord_env.backend.bot_user.id}> please reply")
assert [message.content for message in channel.history()] == [
f"<@{simcord_env.backend.bot_user.id}> please reply",
]
async def test_direct_dm_reply_is_ignored(
self,
simcord_env: Env,
crabstero_bot: Crabstero,
) -> None:
"""The reply helper ignores messages outside guilds."""
user = simcord_env.create_user("Ada")
message = await user.send_dm(f"<@{simcord_env.backend.bot_user.id}> hi")
await reply_to_message(crabstero_bot.db, message)
assert [message.content for message in user.dm_channel.history()] == [
f"<@{simcord_env.backend.bot_user.id}> hi",
]
class TestDeletes:
"""Raw delete events reverse recent message ingestion."""
async def test_delete_reverses_recent_ingest(
self,
simcord_member_channel: SimcordMemberChannel,
crabstero_bot: Crabstero,
start_words_for_channel: StartWordsForChannel,
) -> None:
"""Deleting a cached message removes its Markov rows."""
member = simcord_member_channel.member
channel = simcord_member_channel.channel
message = await member.send(channel, "Delete me.")
assert await start_words_for_channel(crabstero_bot.db, channel.id) == [
"Delete",
]
await member.delete(message)
assert await start_words_for_channel(crabstero_bot.db, channel.id) == []
async def test_bulk_delete_reverses_recent_ingests(
self,
simcord_env: Env,
simcord_member_channel: SimcordMemberChannel,
crabstero_bot: Crabstero,
start_words_for_channel: StartWordsForChannel,
) -> None:
"""Bulk-deleting cached messages removes their Markov rows."""
member = simcord_member_channel.member
channel = simcord_member_channel.channel
first = await member.send(channel, "Bulk one.")
second = await member.send(channel, "Bulk two.")
assert await start_words_for_channel(crabstero_bot.db, channel.id) == [
"Bulk",
"Bulk",
]
simcord_env.backend.bulk_delete_messages(channel.id, [first.id, second.id])
await simcord_env.settle()
assert await start_words_for_channel(crabstero_bot.db, channel.id) == []
class TestFlagSuppression:
"""Message behavior respects persisted noIngest and noReply flags."""
async def test_no_ingest_user_flag_suppresses_ingestion(
self,
simcord_member_channel: SimcordMemberChannel,
crabstero_bot: Crabstero,
start_words_for_channel: StartWordsForChannel,
) -> None:
"""The noIngest flag prevents storing a user's message."""
member = simcord_member_channel.member
channel = simcord_member_channel.channel
await crabstero_bot.db.set_flag(
EntityType.USER,
str(member.id),
Flag.NO_INGEST,
)
await member.send(channel, "Do not learn.")
assert await start_words_for_channel(crabstero_bot.db, channel.id) == []
async def test_direct_dm_ingest_is_ignored(
self,
simcord_env: Env,
crabstero_bot: Crabstero,
start_words_for_channel: StartWordsForChannel,
) -> None:
"""The ingest helper ignores messages outside guilds."""
user = simcord_env.create_user("Ada")
message = await user.send_dm("Direct helper call.")
await ingest_message(crabstero_bot.db, message)
assert (
await start_words_for_channel(
crabstero_bot.db,
user.dm_channel.id,
)
== []
)
async def test_direct_bot_mention_ingest_is_ignored(
self,
simcord_env: Env,
simcord_member_channel: SimcordMemberChannel,
crabstero_bot: Crabstero,
start_words_for_channel: StartWordsForChannel,
) -> None:
"""The ingest helper ignores messages that mention the bot."""
member = simcord_member_channel.member
channel = simcord_member_channel.channel
message = await member.send(
channel,
f"<@{simcord_env.backend.bot_user.id}> do not learn",
)
await ingest_message(crabstero_bot.db, message)
assert await start_words_for_channel(crabstero_bot.db, channel.id) == []
@@ -0,0 +1,277 @@
# 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 server event ingestion triggers."""
from typing import TYPE_CHECKING
import discord
import pytest
from simcord.backend.models import Overwrite
from simcord.enums import OverwriteType
from crabstero.listeners.server_events import ServerEventsCog
from crabstero.tasks.ingestion import ingest_channel
if TYPE_CHECKING:
from simcord import Env
from crabstero.bot import Crabstero
from tests.integration.discord.conftest import (
SimcordMemberChannel,
SimcordTextChannel,
StartWordsForChannel,
)
@pytest.fixture
def server_events_cog(crabstero_bot: Crabstero) -> ServerEventsCog:
"""Return the loaded server-events cog from the Simcord-backed bot."""
cog = crabstero_bot.get_cog("ServerEventsCog")
assert isinstance(cog, ServerEventsCog)
return cog
class TestGuildEvents:
"""Guild availability and joins queue local channel history ingestion."""
async def test_guild_available_queues_text_and_voice_channels(
self,
simcord_env: Env,
server_events_cog: ServerEventsCog,
) -> None:
"""Available guilds enqueue all textable channels."""
guild = simcord_env.create_guild()
await simcord_env.settle()
text = guild.create_text_channel("general")
voice = guild.create_voice_channel("voice")
await simcord_env.settle()
cached_guild = simcord_env.bot.get_guild(guild.id)
assert cached_guild is not None
await server_events_cog.on_guild_available(cached_guild)
await simcord_env.settle()
assert await server_events_cog.bot.db.is_channel_ingested(text.id)
assert await server_events_cog.bot.db.is_channel_ingested(voice.id)
async def test_guild_join_queues_ingestion_and_attempts_owner_notification(
self,
simcord_env: Env,
simcord_text_channel: SimcordTextChannel,
server_events_cog: ServerEventsCog,
) -> None:
"""Joining a guild enqueues ingestion and uses the fake owner DM path."""
guild = simcord_text_channel.guild
channel = simcord_text_channel.channel
cached_guild = simcord_env.bot.get_guild(guild.id)
assert cached_guild is not None
await server_events_cog.on_guild_join(cached_guild)
await simcord_env.settle()
assert await server_events_cog.bot.db.is_channel_ingested(channel.id)
http_routes = [f"{method} {path}" for method, path, _ in simcord_env.http_log]
assert "GET /oauth2/applications/@me" in http_routes
assert "POST /users/@me/channels" in http_routes
assert any(route.endswith("/messages") for route in http_routes)
class TestPermissionUpdateEvents:
"""Permission-changing events trigger ingestion only when relevant."""
async def test_role_update_queues_only_when_permissions_change_for_bot_role(
self,
simcord_env: Env,
simcord_text_channel: SimcordTextChannel,
crabstero_bot: Crabstero,
) -> None:
"""Role updates require changed permissions and bot membership."""
guild = simcord_text_channel.guild
channel = simcord_text_channel.channel
role = guild.create_role("reader", permissions=discord.Permissions.none())
await simcord_env.settle()
simcord_env.backend.edit_role(
guild.id,
role.id,
{"permissions": discord.Permissions(view_channel=True).value},
)
await simcord_env.settle()
assert not await crabstero_bot.db.is_channel_ingested(channel.id)
bot_role = guild.roles[simcord_env.backend.bot_user.name]
simcord_env.backend.edit_role(
guild.id,
bot_role.id,
{
"permissions": discord.Permissions(
view_channel=True,
read_message_history=True,
).value,
},
)
await simcord_env.settle()
assert await crabstero_bot.db.is_channel_ingested(channel.id)
async def test_role_update_same_permissions_does_not_queue(
self,
simcord_env: Env,
simcord_text_channel: SimcordTextChannel,
crabstero_bot: Crabstero,
) -> None:
"""A role update without permission changes is ignored."""
guild = simcord_text_channel.guild
channel = simcord_text_channel.channel
role = guild.create_role(
"reader",
permissions=discord.Permissions(read_message_history=True),
)
simcord_env.backend.add_member_role(
guild.id,
simcord_env.backend.bot_user.id,
role.id,
)
await simcord_env.settle()
simcord_env.backend.edit_role(
guild.id,
role.id,
{"permissions": discord.Permissions(read_message_history=True).value},
)
await simcord_env.settle()
assert not await crabstero_bot.db.is_channel_ingested(channel.id)
async def test_channel_update_queues_only_when_overwrites_change(
self,
simcord_env: Env,
simcord_text_channel: SimcordTextChannel,
crabstero_bot: Crabstero,
) -> None:
"""Channel updates without overwrite changes do not enqueue ingestion."""
guild = simcord_text_channel.guild
channel = simcord_text_channel.channel
simcord_env.backend.edit_channel(channel.id, {"topic": "no permission change"})
await simcord_env.settle()
assert not await crabstero_bot.db.is_channel_ingested(channel.id)
simcord_env.backend.set_overwrite(
channel.id,
Overwrite(
target_id=guild.default_role.id,
type=OverwriteType.ROLE,
allow=discord.Permissions(read_message_history=True).value,
deny=0,
),
)
await simcord_env.settle()
assert await crabstero_bot.db.is_channel_ingested(channel.id)
class TestIngestionTasks:
"""Server-triggered ingestion task behavior stays local and deterministic."""
async def test_duplicate_channel_queue_requests_share_one_task(
self,
simcord_env: Env,
simcord_text_channel: SimcordTextChannel,
crabstero_bot: Crabstero,
) -> None:
"""Queuing the same channel twice before the loop runs creates one task."""
channel = simcord_text_channel.channel
cached_channel = simcord_env.bot.get_channel(channel.id)
assert isinstance(cached_channel, discord.TextChannel)
crabstero_bot.queue_channel_for_ingestion(cached_channel)
crabstero_bot.queue_channel_for_ingestion(cached_channel)
assert list(crabstero_bot._ingestion_tasks) == [channel.id]
await simcord_env.settle()
async def test_channel_history_ingestion_requires_read_history_permission(
self,
simcord_env: Env,
crabstero_bot: Crabstero,
) -> None:
"""A channel missing read history permission is skipped."""
guild = simcord_env.create_guild()
channel = guild.create_text_channel(
"hidden-history",
overwrites={
guild.default_role: discord.PermissionOverwrite(
read_message_history=False,
),
},
)
await simcord_env.settle()
cached_channel = simcord_env.bot.get_channel(channel.id)
assert isinstance(cached_channel, discord.TextChannel)
await ingest_channel(cached_channel, crabstero_bot.db)
assert not await crabstero_bot.db.is_channel_ingested(channel.id)
async def test_channel_history_ingestion_reads_existing_messages(
self,
simcord_env: Env,
simcord_member_channel: SimcordMemberChannel,
crabstero_bot: Crabstero,
start_words_for_channel: StartWordsForChannel,
) -> None:
"""Bulk channel ingestion reads historical messages through Discord."""
member = simcord_member_channel.member
channel = simcord_member_channel.channel
cached_channel = simcord_env.bot.get_channel(channel.id)
assert isinstance(cached_channel, discord.TextChannel)
simcord_env.backend.create_message(
channel.id,
member.id,
"Historical message.",
broadcast=False,
)
await ingest_channel(cached_channel, crabstero_bot.db)
assert await start_words_for_channel(crabstero_bot.db, channel.id) == [
"Historical",
]
assert await crabstero_bot.db.is_channel_ingested(channel.id)
async def test_already_ingested_channel_is_skipped(
self,
simcord_env: Env,
simcord_member_channel: SimcordMemberChannel,
crabstero_bot: Crabstero,
start_words_for_channel: StartWordsForChannel,
) -> None:
"""A channel marked ingested is not read again."""
member = simcord_member_channel.member
channel = simcord_member_channel.channel
await crabstero_bot.db.mark_channel_ingested(channel.id)
cached_channel = simcord_env.bot.get_channel(channel.id)
assert isinstance(cached_channel, discord.TextChannel)
simcord_env.backend.create_message(
channel.id,
member.id,
"Historical message.",
broadcast=False,
)
await ingest_channel(cached_channel, crabstero_bot.db)
assert await start_words_for_channel(crabstero_bot.db, channel.id) == []
+15
View File
@@ -0,0 +1,15 @@
# 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.
"""Metrics integration tests for Crabstero."""
+196
View File
@@ -0,0 +1,196 @@
# 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.
"""Integration tests for the Prometheus metrics HTTP server."""
import errno
import os
import socket
from dataclasses import dataclass
from http import HTTPStatus
from typing import TYPE_CHECKING, Literal, cast
import aiohttp
import pytest
from crabstero.metrics import MetricsServer, TcpMetricsAddress, UnixMetricsAddress
if TYPE_CHECKING:
from collections.abc import AsyncGenerator
from pathlib import Path
type MetricsTransport = Literal["tcp", "unix-socket"]
requires_unix_socket = pytest.mark.skipif(
os.name != "posix" or not hasattr(socket, "AF_UNIX"),
reason="Unix-socket metrics transports require Unix-domain socket support",
)
@dataclass(frozen=True, slots=True)
class MetricsEndpoint:
"""Client details for one running metrics transport."""
base_url: str
unix_socket_path: str | None = None
def client_session(self) -> aiohttp.ClientSession:
"""Create an aiohttp client session for this metrics transport."""
connector = (
aiohttp.UnixConnector(path=self.unix_socket_path)
if self.unix_socket_path is not None
else None
)
return aiohttp.ClientSession(connector=connector)
@pytest.fixture(
params=[
pytest.param("tcp", id="tcp"),
pytest.param("unix-socket", marks=requires_unix_socket, id="unix-socket"),
],
)
async def metrics_endpoint(
request: pytest.FixtureRequest,
tmp_path: Path,
) -> AsyncGenerator[MetricsEndpoint]:
"""Start a MetricsServer on each supported transport."""
transport = cast("MetricsTransport", request.param)
match transport:
case "tcp":
server = MetricsServer(TcpMetricsAddress("127.0.0.1", 0))
await server.start()
endpoint = MetricsEndpoint(f"http://127.0.0.1:{server.port}")
case "unix-socket":
socket_path = tmp_path / "metrics.sock"
server = MetricsServer(UnixMetricsAddress(str(socket_path)))
await server.start()
endpoint = MetricsEndpoint(
"http://crabstero",
unix_socket_path=str(socket_path),
)
try:
yield endpoint
finally:
await server.stop()
class TestMetricsServer:
"""HTTP server serves Prometheus metrics on /metrics."""
@requires_unix_socket
def test_unix_socket_server_has_no_tcp_port(self, tmp_path: Path) -> None:
"""Unix-socket metrics servers do not expose a TCP port."""
server = MetricsServer(UnixMetricsAddress(str(tmp_path / "metrics.sock")))
with pytest.raises(RuntimeError, match="does not have a TCP port"):
_ = server.port
async def test_serves_metrics_endpoint(
self,
metrics_endpoint: MetricsEndpoint,
) -> None:
"""GET /metrics returns 200 with metric output containing our metrics."""
async with (
metrics_endpoint.client_session() as session,
session.get(f"{metrics_endpoint.base_url}/metrics") as resp,
):
assert resp.status == HTTPStatus.OK
body = await resp.text()
assert "crabstero_build_info" in body
async def test_non_metrics_path_returns_404(
self,
metrics_endpoint: MetricsEndpoint,
) -> None:
"""GET on an unknown path returns 404."""
async with (
metrics_endpoint.client_session() as session,
session.get(f"{metrics_endpoint.base_url}/notfound") as resp,
):
assert resp.status == HTTPStatus.NOT_FOUND
@requires_unix_socket
async def test_unix_socket_mode_is_applied(self, tmp_path: Path) -> None:
"""Configured Unix socket mode is applied after startup."""
socket_path = tmp_path / "metrics.sock"
server = MetricsServer(UnixMetricsAddress(str(socket_path), mode=0o666))
await server.start()
try:
assert socket_path.stat().st_mode & 0o777 == 0o666
finally:
await server.stop()
@requires_unix_socket
async def test_stale_unix_socket_path_is_recovered(
self,
tmp_path: Path,
) -> None:
"""Startup removes an abandoned Unix socket file left by a crash."""
socket_path = tmp_path / "metrics.sock"
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as stale_socket:
stale_socket.bind(str(socket_path))
server = MetricsServer(UnixMetricsAddress(str(socket_path), mode=0o666))
await server.start()
try:
assert socket_path.stat().st_mode & 0o777 == 0o666
connector = aiohttp.UnixConnector(path=str(socket_path))
async with (
aiohttp.ClientSession(connector=connector) as session,
session.get("http://crabstero/metrics") as resp,
):
assert resp.status == HTTPStatus.OK
body = await resp.text()
assert "crabstero_build_info" in body
finally:
await server.stop()
@requires_unix_socket
async def test_active_unix_socket_path_fails(
self,
tmp_path: Path,
) -> None:
"""Startup refuses to replace a Unix socket path that is still in use."""
socket_path = tmp_path / "metrics.sock"
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as active_socket:
active_socket.bind(str(socket_path))
active_socket.listen(1)
server = MetricsServer(UnixMetricsAddress(str(socket_path)))
with pytest.raises(OSError, match="already in use") as exc_info:
await server.start()
assert exc_info.value.errno == errno.EADDRINUSE
@requires_unix_socket
async def test_non_socket_unix_path_fails(self, tmp_path: Path) -> None:
"""Startup refuses to replace a non-socket path."""
socket_path = tmp_path / "metrics.sock"
socket_path.write_text("")
server = MetricsServer(UnixMetricsAddress(str(socket_path)))
with pytest.raises(FileExistsError):
await server.start()
async def test_starting_server_twice_fails(self) -> None:
"""A running metrics server cannot be started twice."""
server = MetricsServer(TcpMetricsAddress("127.0.0.1", 0))
await server.start()
try:
with pytest.raises(RuntimeError, match="already running"):
await server.start()
finally:
await server.stop()
+77 -66
View File
@@ -14,39 +14,59 @@
"""Unit tests for the IngestCache TTL cache."""
import asyncio
import time
import pytest
from crabstero.cache import CachedMessage, IngestCache
class _FakeClock:
"""Mutable monotonic clock for deterministic cache expiry tests."""
def __init__(self) -> None:
self.now = 1000.0
def __call__(self) -> float:
"""Return the current fake monotonic timestamp."""
return self.now
def advance(self, seconds: float) -> None:
"""Move the fake clock forward by the given number of seconds."""
self.now += seconds
@pytest.fixture
def cached_message() -> CachedMessage:
"""Return a representative cache entry for put/pop behavior tests."""
return CachedMessage(
channel_id=1,
user_id=100,
content="Hello world",
embed_texts=["embed title"],
image_urls=["https://example.com/cat.png"],
)
@pytest.fixture
def fake_clock(monkeypatch: pytest.MonkeyPatch) -> _FakeClock:
"""Patch the cache's clock and return a controllable timestamp source."""
clock = _FakeClock()
monkeypatch.setattr("crabstero.cache.time.monotonic", clock)
return clock
class TestIngestCache:
"""IngestCache put, pop, and expiry behavior."""
def test_put_and_pop(self) -> None:
def test_put_and_pop(self, cached_message: CachedMessage) -> None:
"""A cached message can be retrieved by message ID."""
cache = IngestCache()
entry = CachedMessage(
channel_id=1,
user_id=100,
content="Hello world",
embed_texts=["embed title"],
image_urls=["https://example.com/cat.png"],
)
cache.put(12345, entry)
assert cache.pop(12345) is entry
cache.put(12345, cached_message)
assert cache.pop(12345) is cached_message
def test_pop_removes_entry(self) -> None:
def test_pop_removes_entry(self, cached_message: CachedMessage) -> None:
"""Popping an entry removes it from the cache."""
cache = IngestCache()
entry = CachedMessage(
channel_id=1,
user_id=100,
content="Hello",
embed_texts=[],
image_urls=[],
)
cache.put(12345, entry)
cache.put(12345, cached_message)
cache.pop(12345)
assert cache.pop(12345) is None
@@ -55,64 +75,55 @@ class TestIngestCache:
cache = IngestCache()
assert cache.pop(99999) is None
def test_expired_entry_not_returned(self) -> None:
def test_expired_entry_not_returned(
self,
cached_message: CachedMessage,
fake_clock: _FakeClock,
) -> None:
"""An expired entry is not returned by pop."""
cache = IngestCache(ttl_seconds=0.01)
entry = CachedMessage(
channel_id=1,
user_id=100,
content="Hello",
embed_texts=[],
image_urls=[],
)
cache.put(12345, entry)
time.sleep(0.02)
cache = IngestCache(ttl_seconds=10)
cache.put(12345, cached_message)
fake_clock.advance(11)
assert cache.pop(12345) is None
def test_cleanup_removes_expired(self) -> None:
def test_cleanup_removes_expired(
self,
cached_message: CachedMessage,
fake_clock: _FakeClock,
) -> None:
"""Cleanup evicts all expired entries."""
cache = IngestCache(ttl_seconds=0.01)
entry = CachedMessage(
channel_id=1,
user_id=100,
content="Hello",
embed_texts=[],
image_urls=[],
)
cache.put(1, entry)
cache.put(2, entry)
time.sleep(0.02)
cache = IngestCache(ttl_seconds=10)
cache.put(1, cached_message)
cache.put(2, cached_message)
fake_clock.advance(11)
cache._cleanup()
assert cache.pop(1) is None
assert cache.pop(2) is None
def test_cleanup_keeps_unexpired(self) -> None:
def test_cleanup_keeps_unexpired(self, cached_message: CachedMessage) -> None:
"""Cleanup does not evict entries that are still valid."""
cache = IngestCache(ttl_seconds=300)
entry = CachedMessage(
channel_id=1,
user_id=100,
content="Hello",
embed_texts=[],
image_urls=[],
)
cache.put(1, entry)
cache.put(1, cached_message)
cache._cleanup()
assert cache.pop(1) is entry
assert cache.pop(1) is cached_message
async def test_start_and_stop(self) -> None:
"""The background cleanup task can be started and stopped."""
cache = IngestCache(ttl_seconds=0, cleanup_interval_seconds=0.01)
entry = CachedMessage(
channel_id=1,
user_id=100,
content="Hello",
embed_texts=[],
image_urls=[],
)
cache.put(1, entry)
cache = IngestCache()
cache.start()
await asyncio.sleep(0.05)
assert cache._task is not None
await cache.stop()
# Entry should have been cleaned up by the background task.
assert cache.pop(1) is None
assert cache._task is None
async def test_start_is_idempotent(self) -> None:
"""Starting an already-started cache keeps the existing cleanup task."""
cache = IngestCache()
cache.start()
try:
task = cache._task
cache.start()
assert cache._task is task
finally:
await cache.stop()
+103 -112
View File
@@ -20,7 +20,6 @@ Tests cover the CLI helpers and runtime orchestration from crabstero.cli.
import asyncio
import os
import signal
import socket
import sys
from typing import TYPE_CHECKING, Any, ClassVar, Self
@@ -43,12 +42,37 @@ if TYPE_CHECKING:
class _FakeCrabstero:
"""Fake bot that records CLI lifecycle calls without touching Discord.
Tests can inject callbacks during login/connect and exceptions during
login/close to exercise CLI shutdown ordering and propagation paths.
"""
events: ClassVar[list[object]] = []
login_error: ClassVar[BaseException | None] = None
login_callback: ClassVar[Callable[[], None] | None] = None
close_error: ClassVar[BaseException | None] = None
connect_callback: ClassVar[Callable[[], None] | None] = None
@classmethod
def reset(
cls,
*,
login_error: BaseException | None = None,
close_error: BaseException | None = None,
) -> list[object]:
"""Clear scenario state and return the shared lifecycle event log.
The event log is shared across fake instances so tests can assert the
ordering of construction, login, notifications, connect, and close.
"""
cls.events = []
cls.login_error = login_error
cls.login_callback = None
cls.close_error = close_error
cls.connect_callback = None
return cls.events
def __init__(self, **kwargs: object) -> None:
self._closed = False
self.events.append(("init", kwargs))
@@ -94,34 +118,68 @@ class _FakeCrabstero:
return self._closed
def _run_coroutine(
coro: Coroutine[Any, Any, signal.Signals | None],
) -> signal.Signals | None:
return asyncio.run(coro)
def _prepare_main_test(
@pytest.fixture
def prepared_main(
monkeypatch: pytest.MonkeyPatch,
*,
login_error: BaseException | None = None,
close_error: BaseException | None = None,
) -> list[object]:
_FakeCrabstero.events = []
_FakeCrabstero.login_error = login_error
_FakeCrabstero.login_callback = None
_FakeCrabstero.close_error = close_error
_FakeCrabstero.connect_callback = None
) -> Callable[..., list[object]]:
"""Return a factory that prepares cli.main for fake-bot lifecycle tests.
def sd_notify(state: str) -> None:
_FakeCrabstero.events.append(("notify", state))
Each factory call resets the fake bot, routes systemd notifications into
the event log, disables watchdog setup, and makes uvloop.run execute the
coroutine synchronously through asyncio.run.
"""
monkeypatch.setattr(sys, "argv", ["crabstero", "--token", "cli-token"])
monkeypatch.setattr(cli, "Crabstero", _FakeCrabstero)
monkeypatch.setattr("crabstero.cli.uvloop.run", _run_coroutine)
monkeypatch.setattr(cli, "_sd_notify", sd_notify)
monkeypatch.setattr(cli, "_watchdog_interval", lambda: None)
def prepare(
*,
login_error: BaseException | None = None,
close_error: BaseException | None = None,
) -> list[object]:
events = _FakeCrabstero.reset(
login_error=login_error,
close_error=close_error,
)
return _FakeCrabstero.events
def sd_notify(state: str) -> None:
events.append(("notify", state))
monkeypatch.setattr(sys, "argv", ["crabstero", "--token", "cli-token"])
monkeypatch.setattr(cli, "Crabstero", _FakeCrabstero)
monkeypatch.setattr("crabstero.cli.uvloop.run", asyncio.run)
monkeypatch.setattr(cli, "_sd_notify", sd_notify)
monkeypatch.setattr(cli, "_watchdog_interval", lambda: None)
return events
return prepare
@pytest.fixture
def signal_handlers(
monkeypatch: pytest.MonkeyPatch,
) -> dict[int, Callable[[], None]]:
"""Patch signal registration and return captured signal callbacks.
The returned mapping lets tests invoke registered SIGINT/SIGTERM handlers
directly and assert that cleanup removes them.
"""
handlers: dict[int, Callable[[], None]] = {}
class SignalLoop:
"""Minimal event-loop facade that stores installed signal handlers."""
def add_signal_handler(
self,
sig: int,
callback: Callable[..., None],
*args: object,
) -> None:
handlers[sig] = lambda: callback(*args)
def remove_signal_handler(self, sig: int) -> None:
handlers.pop(sig, None)
monkeypatch.setattr(asyncio, "get_running_loop", SignalLoop)
return handlers
class TestParseArgs:
@@ -322,23 +380,6 @@ class TestSystemdNotify:
monkeypatch.delenv("NOTIFY_SOCKET", raising=False)
_sd_notify("READY=1")
def test_sd_notify_sends_datagram(
self,
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""_sd_notify sends the payload to NOTIFY_SOCKET."""
socket_path = tmp_path / "notify.sock"
with socket.socket(socket.AF_UNIX, socket.SOCK_DGRAM) as server:
server.bind(str(socket_path))
server.settimeout(1)
monkeypatch.setenv("NOTIFY_SOCKET", str(socket_path))
_sd_notify("READY=1")
assert server.recv(1024) == b"READY=1"
class TestWatchdog:
"""Systemd watchdog helper behavior."""
@@ -426,10 +467,10 @@ class TestMainLifecycle:
def test_constructs_bot_without_token(
self,
monkeypatch: pytest.MonkeyPatch,
prepared_main: Callable[..., list[object]],
) -> None:
"""The CLI keeps the Discord token out of Crabstero construction."""
events = _prepare_main_test(monkeypatch)
events = prepared_main()
assert cli.main() == 0
@@ -449,14 +490,12 @@ class TestMainLifecycle:
def test_main_accepts_explicit_argv(
self,
monkeypatch: pytest.MonkeyPatch,
prepared_main: Callable[..., list[object]],
) -> None:
"""main(argv) runs from explicit arguments instead of sys.argv."""
events = _prepare_main_test(monkeypatch)
events = prepared_main()
assert (
cli.main(["--token", "argv-token", "--database-path", "/custom.db"]) == 0
)
assert cli.main(["--token", "argv-token", "--database-path", "/custom.db"]) == 0
assert events[0] == (
"init",
@@ -470,10 +509,10 @@ class TestMainLifecycle:
def test_ready_sent_after_login_before_connect(
self,
monkeypatch: pytest.MonkeyPatch,
prepared_main: Callable[..., list[object]],
) -> None:
"""READY=1 is sent after login succeeds and before connect starts."""
events = _prepare_main_test(monkeypatch)
events = prepared_main()
assert cli.main() == 0
@@ -486,10 +525,10 @@ class TestMainLifecycle:
def test_stopping_sent_after_clean_connect_return(
self,
monkeypatch: pytest.MonkeyPatch,
prepared_main: Callable[..., list[object]],
) -> None:
"""STOPPING=1 is sent when the CLI run exits cleanly after readiness."""
events = _prepare_main_test(monkeypatch)
events = prepared_main()
assert cli.main() == 0
@@ -499,13 +538,10 @@ class TestMainLifecycle:
def test_ready_not_sent_when_login_fails(
self,
monkeypatch: pytest.MonkeyPatch,
prepared_main: Callable[..., list[object]],
) -> None:
"""A login failure exits without reporting readiness."""
events = _prepare_main_test(
monkeypatch,
login_error=RuntimeError("login failed"),
)
events = prepared_main(login_error=RuntimeError("login failed"))
with pytest.raises(RuntimeError, match="login failed"):
cli.main()
@@ -515,32 +551,15 @@ class TestMainLifecycle:
def test_shutdown_task_exception_is_propagated(
self,
monkeypatch: pytest.MonkeyPatch,
prepared_main: Callable[..., list[object]],
signal_handlers: dict[int, Callable[[], None]],
) -> None:
"""A completed shutdown task exception is not silently dropped."""
signal_handlers: dict[int, Callable[[], None]] = {}
class SignalLoop:
def add_signal_handler(
self,
sig: int,
callback: Callable[..., None],
*args: object,
) -> None:
signal_handlers[sig] = lambda: callback(*args)
def remove_signal_handler(self, sig: int) -> None:
signal_handlers.pop(sig, None)
events = _prepare_main_test(
monkeypatch,
close_error=RuntimeError("close failed"),
)
events = prepared_main(close_error=RuntimeError("close failed"))
def request_shutdown() -> None:
signal_handlers[signal.SIGTERM]()
monkeypatch.setattr(asyncio, "get_running_loop", SignalLoop)
_FakeCrabstero.connect_callback = request_shutdown
with pytest.raises(RuntimeError, match="close failed"):
@@ -559,29 +578,15 @@ class TestMainLifecycle:
self,
sig: signal.Signals,
exit_code: int,
monkeypatch: pytest.MonkeyPatch,
prepared_main: Callable[..., list[object]],
signal_handlers: dict[int, Callable[[], None]],
) -> None:
"""Signal callbacks preserve SIGINT and SIGTERM CLI exit semantics."""
signal_handlers: dict[int, Callable[[], None]] = {}
class SignalLoop:
def add_signal_handler(
self,
sig: int,
callback: Callable[..., None],
*args: object,
) -> None:
signal_handlers[sig] = lambda: callback(*args)
def remove_signal_handler(self, sig: int) -> None:
signal_handlers.pop(sig, None)
events = _prepare_main_test(monkeypatch)
events = prepared_main()
def request_shutdown() -> None:
signal_handlers[sig]()
monkeypatch.setattr(asyncio, "get_running_loop", SignalLoop)
_FakeCrabstero.connect_callback = request_shutdown
assert cli.main() == exit_code
@@ -591,29 +596,15 @@ class TestMainLifecycle:
def test_registered_signal_during_login_exits_before_ready(
self,
monkeypatch: pytest.MonkeyPatch,
prepared_main: Callable[..., list[object]],
signal_handlers: dict[int, Callable[[], None]],
) -> None:
"""A signal during login cancels startup without reporting readiness."""
signal_handlers: dict[int, Callable[[], None]] = {}
class SignalLoop:
def add_signal_handler(
self,
sig: int,
callback: Callable[..., None],
*args: object,
) -> None:
signal_handlers[sig] = lambda: callback(*args)
def remove_signal_handler(self, sig: int) -> None:
signal_handlers.pop(sig, None)
events = _prepare_main_test(monkeypatch)
events = prepared_main()
def request_shutdown() -> None:
signal_handlers[signal.SIGINT]()
monkeypatch.setattr(asyncio, "get_running_loop", SignalLoop)
_FakeCrabstero.login_callback = request_shutdown
assert cli.main() == 130
+64 -16
View File
@@ -15,13 +15,13 @@
"""Unit tests for the flag convenience wrappers and enums.
Tests cover _entity_id, Flag and EntityType enums, and the high-level
set_flag/clear_flag/is_flag_set wrappers from crabstero.flags.
set_flag/clear_flag/is_flag_set wrappers from crabstero.flags without crossing
the SQLite database boundary.
"""
from typing import TYPE_CHECKING
import pytest
from crabstero.database import Database
from crabstero.flags import (
EntityType,
Flag,
@@ -31,17 +31,49 @@ from crabstero.flags import (
set_flag,
)
if TYPE_CHECKING:
from crabstero.database import Database
class _StubSnowflake:
"""Minimal stand-in for discord.abc.Snowflake."""
"""Minimal stand-in for objects that expose a Discord snowflake ID."""
def __init__(self, *, entity_id: int = 123456789) -> None:
self.id = entity_id
class _FakeFlagDatabase(Database):
"""In-memory flag store that records wrapper calls below SQLite."""
def __init__(self) -> None:
self.flags: set[tuple[str, str, str]] = set()
async def set_flag(self, entity_type: str, entity_id: str, flag_name: str) -> None:
"""Record a flag set operation."""
self.flags.add((entity_type, entity_id, flag_name))
async def clear_flag(
self,
entity_type: str,
entity_id: str,
flag_name: str,
) -> None:
"""Record a flag clear operation."""
self.flags.discard((entity_type, entity_id, flag_name))
async def is_flag_set(
self,
entity_type: str,
entity_id: str,
flag_name: str,
) -> bool:
"""Return whether a flag has been set."""
return (entity_type, entity_id, flag_name) in self.flags
@pytest.fixture
def flag_db() -> _FakeFlagDatabase:
"""Return an isolated flag store for wrapper tests."""
return _FakeFlagDatabase()
class TestEntityId:
"""Entity ID extraction from Discord objects and integers."""
@@ -100,17 +132,33 @@ class TestSetClearCheck:
pytest.param(Flag.ALLOW_PINGS, id="allow-pings"),
],
)
async def test_set_then_check(self, db: Database, flag: Flag) -> None:
async def test_set_then_check(self, flag_db: _FakeFlagDatabase, flag: Flag) -> None:
"""A set flag is reported as set."""
await set_flag(db, 1, EntityType.CHANNEL, flag)
assert await is_flag_set(db, 1, EntityType.CHANNEL, flag) is True
await set_flag(flag_db, 1, EntityType.CHANNEL, flag)
assert await is_flag_set(flag_db, 1, EntityType.CHANNEL, flag) is True
async def test_unset_returns_false(self, db: Database) -> None:
async def test_unset_returns_false(self, flag_db: _FakeFlagDatabase) -> None:
"""An unset flag is reported as not set."""
assert await is_flag_set(db, 1, EntityType.CHANNEL, Flag.NO_REPLY) is False
assert (
await is_flag_set(
flag_db,
1,
EntityType.CHANNEL,
Flag.NO_REPLY,
)
is False
)
async def test_clear_removes_flag(self, db: Database) -> None:
async def test_clear_removes_flag(self, flag_db: _FakeFlagDatabase) -> None:
"""A cleared flag is no longer reported as set."""
await set_flag(db, 1, EntityType.CHANNEL, Flag.NO_REPLY)
await clear_flag(db, 1, EntityType.CHANNEL, Flag.NO_REPLY)
assert await is_flag_set(db, 1, EntityType.CHANNEL, Flag.NO_REPLY) is False
await set_flag(flag_db, 1, EntityType.CHANNEL, Flag.NO_REPLY)
await clear_flag(flag_db, 1, EntityType.CHANNEL, Flag.NO_REPLY)
assert (
await is_flag_set(
flag_db,
1,
EntityType.CHANNEL,
Flag.NO_REPLY,
)
is False
)
+250 -128
View File
@@ -15,14 +15,14 @@
"""Unit tests for Markov chain ingestion and generation.
Tests cover is_complete_sentence, _ingest_sentence, ingest, and generate
from crabstero.markov.
from crabstero.markov without crossing the SQLite database boundary.
"""
from typing import TYPE_CHECKING
import contextlib
import pytest
from crabstero.database import StartWord, Transition
from crabstero.database import Database, StartWord, Transition
from crabstero.markov import (
DEFAULT_SENTENCE_END,
_ingest_sentence,
@@ -33,8 +33,80 @@ from crabstero.markov import (
uningest,
)
if TYPE_CHECKING:
from crabstero.database import Database
class _FakeMarkovDatabase(Database):
"""In-memory Markov store with deterministic first-row selection.
The fake preserves duplicate rows and one-row-at-a-time removal semantics
so ingestion and uningestion tests can stay below the SQLite boundary.
"""
def __init__(self) -> None:
self.start_words: list[StartWord] = []
self.transitions: list[Transition] = []
async def add_markov_data(
self,
start_words: list[StartWord],
transitions: list[Transition],
) -> None:
"""Record Markov rows exactly as the application would write them."""
self.start_words.extend(start_words)
self.transitions.extend(transitions)
async def remove_markov_data(
self,
start_words: list[StartWord],
transitions: list[Transition],
) -> None:
"""Remove at most one matching row per requested Markov row."""
for start_word in start_words:
with contextlib.suppress(ValueError):
self.start_words.remove(start_word)
for transition in transitions:
with contextlib.suppress(ValueError):
self.transitions.remove(transition)
async def get_random_start_word(self, channel_id: int) -> str | None:
"""Return the first start word for deterministic generation tests."""
return next(
(row.word for row in self.start_words if row.channel_id == channel_id),
None,
)
async def get_random_next_word(self, channel_id: int, word: str) -> str | None:
"""Return the first matching transition for deterministic tests."""
return next(
(
row.next_word
for row in self.transitions
if row.channel_id == channel_id and row.word == word
),
None,
)
async def get_random_completing_next_word(
self,
channel_id: int,
word: str,
) -> str | None:
"""Return the first sentence-ending transition for deterministic tests."""
return next(
(
row.next_word
for row in self.transitions
if row.channel_id == channel_id
and row.word == word
and is_complete_sentence(row.next_word)
),
None,
)
@pytest.fixture
def markov_db() -> _FakeMarkovDatabase:
"""Return an isolated deterministic Markov store for one test."""
return _FakeMarkovDatabase()
class TestIsCompleteSentence:
@@ -61,83 +133,93 @@ class TestIsCompleteSentence:
class TestIngestSentence:
"""Single sentence ingestion into the Markov chain."""
async def test_stores_start_word(self, db: Database) -> None:
async def test_stores_start_word(self, markov_db: _FakeMarkovDatabase) -> None:
"""First word of the sentence is stored as a start word."""
await _ingest_sentence(db, 1, 100, "Hello world.")
await _ingest_sentence(markov_db, 1, 100, "Hello world.")
result = await db.get_random_start_word(1)
assert result == "Hello"
assert markov_db.start_words == [StartWord(1, 100, "Hello")]
async def test_stores_transitions(self, db: Database) -> None:
async def test_stores_transitions(self, markov_db: _FakeMarkovDatabase) -> None:
"""Adjacent words create transitions."""
await _ingest_sentence(db, 1, 100, "Hello world.")
await _ingest_sentence(markov_db, 1, 100, "Hello world.")
result = await db.get_random_next_word(1, "Hello")
assert result == "world."
assert markov_db.transitions == [Transition(1, 100, "Hello", "world.")]
async def test_appends_sentence_end_if_missing(self, db: Database) -> None:
async def test_appends_sentence_end_if_missing(
self,
markov_db: _FakeMarkovDatabase,
) -> None:
"""Unpunctuated sentence gets the default sentence-end marker."""
await _ingest_sentence(db, 1, 100, "Hello world")
await _ingest_sentence(markov_db, 1, 100, "Hello world")
result = await db.get_random_next_word(1, "Hello")
assert result == f"world{DEFAULT_SENTENCE_END}"
assert markov_db.transitions == [
Transition(1, 100, "Hello", f"world{DEFAULT_SENTENCE_END}"),
]
async def test_preserves_existing_punctuation(self, db: Database) -> None:
async def test_preserves_existing_punctuation(
self,
markov_db: _FakeMarkovDatabase,
) -> None:
"""Already-punctuated sentence keeps its terminator."""
await _ingest_sentence(db, 1, 100, "Hello world!")
await _ingest_sentence(markov_db, 1, 100, "Hello world!")
result = await db.get_random_next_word(1, "Hello")
assert result == "world!"
assert markov_db.transitions == [Transition(1, 100, "Hello", "world!")]
async def test_single_word_stores_nothing(self, db: Database) -> None:
async def test_single_word_stores_nothing(
self,
markov_db: _FakeMarkovDatabase,
) -> None:
"""A single-word sentence produces no start words or transitions."""
await _ingest_sentence(db, 1, 100, "Hello.")
await _ingest_sentence(markov_db, 1, 100, "Hello.")
assert await db.get_random_start_word(1) is None
assert await db.get_random_next_word(1, "Hello.") is None
assert markov_db.start_words == []
assert markov_db.transitions == []
async def test_stores_all_transitions(self, db: Database) -> None:
async def test_stores_all_transitions(
self,
markov_db: _FakeMarkovDatabase,
) -> None:
"""All adjacent word pairs create transitions."""
await _ingest_sentence(db, 1, 100, "A B C.")
await _ingest_sentence(markov_db, 1, 100, "A B C.")
assert await db.get_random_next_word(1, "A") == "B"
assert await db.get_random_next_word(1, "B") == "C."
assert markov_db.transitions == [
Transition(1, 100, "A", "B"),
Transition(1, 100, "B", "C."),
]
class TestIngestParagraph:
"""Paragraph ingestion splits into sentences."""
async def test_single_sentence(self, db: Database) -> None:
async def test_single_sentence(self, markov_db: _FakeMarkovDatabase) -> None:
"""A single sentence paragraph is ingested."""
await ingest(db, 1, 100, "Hello world.")
await ingest(markov_db, 1, 100, "Hello world.")
assert await db.get_random_start_word(1) == "Hello"
assert markov_db.start_words == [StartWord(1, 100, "Hello")]
async def test_multiple_sentences(self, db: Database) -> None:
async def test_multiple_sentences(self, markov_db: _FakeMarkovDatabase) -> None:
"""Multiple sentences are split and ingested individually."""
await ingest(db, 1, 100, "Hello world. Goodbye world!")
await ingest(markov_db, 1, 100, "Hello world. Goodbye world!")
# Both "Hello" and "Goodbye" should appear as start words.
async with db._connection.execute(
"SELECT DISTINCT word FROM markov_start_words WHERE channel_id = ?",
(1,),
) as cursor:
start_words = {row[0] for row in await cursor.fetchall()}
assert start_words == {"Hello", "Goodbye"}
assert {row.word for row in markov_db.start_words} == {"Hello", "Goodbye"}
async def test_normalizes_whitespace(self, db: Database) -> None:
async def test_normalizes_whitespace(
self,
markov_db: _FakeMarkovDatabase,
) -> None:
"""Extra spaces and newlines are collapsed."""
await ingest(db, 1, 100, "Hello world.\nGoodbye world!")
await ingest(markov_db, 1, 100, "Hello world.\nGoodbye world!")
result = await db.get_random_next_word(1, "Hello")
assert result == "world."
assert Transition(1, 100, "Hello", "world.") in markov_db.transitions
assert Transition(1, 100, "Goodbye", "world!") in markov_db.transitions
async def test_appends_default_end(self, db: Database) -> None:
async def test_appends_default_end(self, markov_db: _FakeMarkovDatabase) -> None:
"""Unpunctuated paragraph gets the default sentence-end marker."""
await ingest(db, 1, 100, "Hello world")
await ingest(markov_db, 1, 100, "Hello world")
result = await db.get_random_next_word(1, "Hello")
assert result == f"world{DEFAULT_SENTENCE_END}"
assert markov_db.transitions == [
Transition(1, 100, "Hello", f"world{DEFAULT_SENTENCE_END}"),
]
@pytest.mark.parametrize(
"paragraph",
@@ -148,72 +230,91 @@ class TestIngestParagraph:
)
async def test_empty_input_stores_nothing(
self,
db: Database,
markov_db: _FakeMarkovDatabase,
paragraph: str,
) -> None:
"""Empty or whitespace-only input does not store any data."""
await ingest(db, 1, 100, paragraph)
await ingest(markov_db, 1, 100, paragraph)
assert await db.get_random_start_word(1) is None
assert markov_db.start_words == []
assert markov_db.transitions == []
async def test_splits_on_punctuation_followed_by_space(self, db: Database) -> None:
async def test_splits_on_punctuation_followed_by_space(
self,
markov_db: _FakeMarkovDatabase,
) -> None:
"""Punctuation followed by a space splits into separate sentences."""
await ingest(db, 1, 100, "Dr. Smith likes cats")
await ingest(markov_db, 1, 100, "Dr. Smith likes cats")
# "Dr." splits off as a single-word sentence (stores nothing).
# "Smith likes cats" becomes a sentence with "Smith" as start word.
assert await db.get_random_start_word(1) == "Smith"
assert markov_db.start_words == [StartWord(1, 100, "Smith")]
async def test_no_split_without_space_after_punctuation(self, db: Database) -> None:
async def test_no_split_without_space_after_punctuation(
self,
markov_db: _FakeMarkovDatabase,
) -> None:
"""Punctuation not followed by a space keeps words together."""
await ingest(db, 1, 100, "Hello.World is here")
await ingest(markov_db, 1, 100, "Hello.World is here")
assert await db.get_random_start_word(1) == "Hello.World"
assert markov_db.start_words == [StartWord(1, 100, "Hello.World")]
class TestGenerate:
"""Markov chain text generation."""
async def test_fallback_on_empty_channel(self, db: Database) -> None:
async def test_fallback_on_empty_channel(
self,
markov_db: _FakeMarkovDatabase,
) -> None:
"""Returns an informational message when the channel has no data."""
result = await generate(db, 1)
result = await generate(markov_db, 1)
expected = (
"I do not have enough data to generate a message yet."
" Chat a bit more so I can learn how this channel talks."
)
assert result == expected
async def test_generates_from_ingested_data(self, db: Database) -> None:
async def test_generates_from_ingested_data(
self,
markov_db: _FakeMarkovDatabase,
) -> None:
"""Generated text uses words from ingested data."""
await ingest(db, 1, 100, "The quick brown fox.")
await ingest(markov_db, 1, 100, "The quick brown fox.")
result = await generate(db, 1)
result = await generate(markov_db, 1)
assert result == "The quick brown fox."
async def test_strips_section_sign(self, db: Database) -> None:
async def test_strips_section_sign(self, markov_db: _FakeMarkovDatabase) -> None:
"""The internal section sign marker never appears in output."""
await ingest(db, 1, 100, "Hello world")
await ingest(markov_db, 1, 100, "Hello world")
result = await generate(db, 1)
result = await generate(markov_db, 1)
assert result == "Hello world"
async def test_respects_hard_limit(self, db: Database) -> None:
async def test_respects_hard_limit(
self,
markov_db: _FakeMarkovDatabase,
) -> None:
"""Output is truncated at hard_limit."""
words = [f"w{i}" for i in range(100)]
text = " ".join(words)
await ingest(db, 1, 100, text)
await ingest(markov_db, 1, 100, text)
result = await generate(db, 1, soft_limit=10, hard_limit=49)
result = await generate(markov_db, 1, soft_limit=10, hard_limit=49)
assert result == "w0 w1 w2 w3 w4 w5 w6 w7 w8 w9 w10 w11 w12 w13 w14"
async def test_prefers_completing_word_after_soft_limit(self, db: Database) -> None:
async def test_prefers_completing_word_after_soft_limit(
self,
markov_db: _FakeMarkovDatabase,
) -> None:
"""After soft_limit, generation prefers completing words."""
# Chain: A → B → C → D → {E, "end."}
# Chain: A -> B -> C -> D -> {E, "end."}
# A, B, C have only non-completing transitions, so past the soft limit
# the loop falls back to get_random_next_word for each. D has both a
# continuing ("E") and completing ("end.") transition, so
# get_random_completing_next_word deterministically picks "end.".
await db.add_markov_data(
await markov_db.add_markov_data(
[StartWord(1, 100, "A")],
[
Transition(1, 100, "A", "B"),
@@ -227,29 +328,32 @@ class TestGenerate:
],
)
for _ in range(100):
result = await generate(db, 1, soft_limit=1, hard_limit=1000)
assert result == "A B C D end."
result = await generate(markov_db, 1, soft_limit=1, hard_limit=1000)
assert result == "A B C D end."
async def test_start_word_already_ends_sentence(self, db: Database) -> None:
async def test_start_word_already_ends_sentence(
self,
markov_db: _FakeMarkovDatabase,
) -> None:
"""Generation stops immediately when the start word is sentence-ending."""
await db.add_markov_data([StartWord(1, 100, "Yes.")], [])
await markov_db.add_markov_data([StartWord(1, 100, "Yes.")], [])
result = await generate(db, 1)
result = await generate(markov_db, 1)
assert result == "Yes."
async def test_chain_dead_end(self, db: Database) -> None:
async def test_chain_dead_end(self, markov_db: _FakeMarkovDatabase) -> None:
"""Generation stops when no next word exists (dead-end chain)."""
await db.add_markov_data([StartWord(1, 100, "Hello")], [])
await markov_db.add_markov_data([StartWord(1, 100, "Hello")], [])
# "Hello" has no transitions, so the loop breaks immediately.
result = await generate(db, 1)
result = await generate(markov_db, 1)
assert result == "Hello"
async def test_hard_limit_strips_section_sign(self, db: Database) -> None:
async def test_hard_limit_strips_section_sign(
self,
markov_db: _FakeMarkovDatabase,
) -> None:
"""Section sign at the truncation boundary is stripped."""
# Build a chain: "A" -> "B" -> "C§".
await db.add_markov_data(
await markov_db.add_markov_data(
[StartWord(1, 100, "A")],
[
Transition(1, 100, "A", "B"),
@@ -257,43 +361,41 @@ class TestGenerate:
],
)
# hard_limit=5 truncates "A B C§" (length 6) to "A B C".
result = await generate(db, 1, soft_limit=100, hard_limit=5)
result = await generate(markov_db, 1, soft_limit=100, hard_limit=6)
assert result == "A B C"
async def test_soft_limit_falls_back_to_regular_next_word(
self,
db: Database,
markov_db: _FakeMarkovDatabase,
) -> None:
"""After soft_limit, falls back when no completing word exists."""
# "A" -> "B" (no completing transition). Past soft_limit,
# get_random_completing_next_word returns None, falls back to "B".
await db.add_markov_data(
await markov_db.add_markov_data(
[StartWord(1, 100, "A")],
[Transition(1, 100, "A", "B")],
)
result = await generate(db, 1, soft_limit=1, hard_limit=1000)
result = await generate(markov_db, 1, soft_limit=1, hard_limit=1000)
assert result == "A B"
async def test_hard_limit_truncates_mid_word(self, db: Database) -> None:
async def test_hard_limit_truncates_mid_word(
self,
markov_db: _FakeMarkovDatabase,
) -> None:
"""Hard limit slices output even when it falls inside a word."""
await db.add_markov_data(
await markov_db.add_markov_data(
[StartWord(1, 100, "AB")],
[Transition(1, 100, "AB", "CDEF")],
)
# "AB CDEF" is 7 chars; hard_limit=5 truncates to "AB CD".
result = await generate(db, 1, soft_limit=100, hard_limit=5)
result = await generate(markov_db, 1, soft_limit=100, hard_limit=5)
assert result == "AB CD"
async def test_channel_isolation(self, db: Database) -> None:
async def test_channel_isolation(self, markov_db: _FakeMarkovDatabase) -> None:
"""Data ingested into one channel does not leak into another."""
await ingest(db, 1, 100, "Channel one data.")
await ingest(db, 2, 100, "Channel two data.")
await ingest(markov_db, 1, 100, "Channel one data.")
await ingest(markov_db, 2, 100, "Channel two data.")
# Channel 3 has no data; should get the fallback.
result = await generate(db, 3)
result = await generate(markov_db, 3)
expected = (
"I do not have enough data to generate a message yet."
" Chat a bit more so I can learn how this channel talks."
@@ -304,45 +406,65 @@ class TestGenerate:
class TestUningestSentence:
"""Single sentence uningest from the Markov chain."""
async def test_removes_start_word_and_transition(self, db: Database) -> None:
async def test_removes_start_word_and_transition(
self,
markov_db: _FakeMarkovDatabase,
) -> None:
"""Uningest removes the start word and transition added by ingest."""
await _ingest_sentence(db, 1, 100, "Hello world.")
await _uningest_sentence(db, 1, 100, "Hello world.")
assert await db.get_random_start_word(1) is None
assert await db.get_random_next_word(1, "Hello") is None
await _ingest_sentence(markov_db, 1, 100, "Hello world.")
await _uningest_sentence(markov_db, 1, 100, "Hello world.")
assert markov_db.start_words == []
assert markov_db.transitions == []
async def test_preserves_other_data(self, db: Database) -> None:
async def test_preserves_other_data(
self,
markov_db: _FakeMarkovDatabase,
) -> None:
"""Uningest only removes data for the specified sentence."""
await _ingest_sentence(db, 1, 100, "Hello world.")
await _ingest_sentence(db, 1, 100, "Goodbye world.")
await _uningest_sentence(db, 1, 100, "Hello world.")
assert await db.get_random_start_word(1) == "Goodbye"
await _ingest_sentence(markov_db, 1, 100, "Hello world.")
await _ingest_sentence(markov_db, 1, 100, "Goodbye world.")
await _uningest_sentence(markov_db, 1, 100, "Hello world.")
assert markov_db.start_words == [StartWord(1, 100, "Goodbye")]
assert markov_db.transitions == [Transition(1, 100, "Goodbye", "world.")]
async def test_handles_missing_punctuation(self, db: Database) -> None:
async def test_handles_missing_punctuation(
self,
markov_db: _FakeMarkovDatabase,
) -> None:
"""Uningest appends the default sentence end, matching ingest behavior."""
await _ingest_sentence(db, 1, 100, "Hello world")
await _uningest_sentence(db, 1, 100, "Hello world")
assert await db.get_random_start_word(1) is None
await _ingest_sentence(markov_db, 1, 100, "Hello world")
await _uningest_sentence(markov_db, 1, 100, "Hello world")
assert markov_db.start_words == []
assert markov_db.transitions == []
async def test_single_word_is_noop(self, db: Database) -> None:
async def test_single_word_is_noop(self, markov_db: _FakeMarkovDatabase) -> None:
"""Uningesting a single-word sentence does not error."""
await _ingest_sentence(db, 1, 100, "Hello.")
await _uningest_sentence(db, 1, 100, "Hello.")
await _ingest_sentence(markov_db, 1, 100, "Hello.")
await _uningest_sentence(markov_db, 1, 100, "Hello.")
assert markov_db.start_words == []
assert markov_db.transitions == []
class TestUningestParagraph:
"""Paragraph-level uningest."""
async def test_uningest_multiple_sentences(self, db: Database) -> None:
async def test_uningest_multiple_sentences(
self,
markov_db: _FakeMarkovDatabase,
) -> None:
"""Uningest reverses a multi-sentence paragraph."""
await ingest(db, 1, 100, "Hello world. Goodbye world!")
await uningest(db, 1, 100, "Hello world. Goodbye world!")
assert await db.get_random_start_word(1) is None
await ingest(markov_db, 1, 100, "Hello world. Goodbye world!")
await uningest(markov_db, 1, 100, "Hello world. Goodbye world!")
assert markov_db.start_words == []
assert markov_db.transitions == []
async def test_uningest_preserves_duplicate_data(self, db: Database) -> None:
async def test_uningest_preserves_duplicate_data(
self,
markov_db: _FakeMarkovDatabase,
) -> None:
"""Uningesting one copy leaves the other intact."""
await ingest(db, 1, 100, "Hello world.")
await ingest(db, 1, 100, "Hello world.")
await uningest(db, 1, 100, "Hello world.")
assert await db.get_random_start_word(1) == "Hello"
assert await db.get_random_next_word(1, "Hello") == "world."
await ingest(markov_db, 1, 100, "Hello world.")
await ingest(markov_db, 1, 100, "Hello world.")
await uningest(markov_db, 1, 100, "Hello world.")
assert markov_db.start_words == [StartWord(1, 100, "Hello")]
assert markov_db.transitions == [Transition(1, 100, "Hello", "world.")]
+9 -152
View File
@@ -14,44 +14,20 @@
"""Unit tests for the Prometheus metrics module.
Tests cover metric object registration and the MetricsServer HTTP endpoint.
Tests cover metric object registration.
"""
import errno
import socket
from dataclasses import dataclass
from http import HTTPStatus
from typing import TYPE_CHECKING, Literal, cast
import aiohttp
import pytest
from prometheus_client import generate_latest
from crabstero.metrics import MetricsServer, TcpMetricsAddress, UnixMetricsAddress
if TYPE_CHECKING:
from collections.abc import AsyncGenerator
from pathlib import Path
# Importing the module registers its Prometheus metrics with the default registry.
import crabstero.metrics # noqa: F401 - imported for registration side effects
type MetricsTransport = Literal["tcp", "unix-socket"]
@dataclass(frozen=True, slots=True)
class MetricsEndpoint:
"""Client details for one running metrics transport."""
base_url: str
unix_socket_path: str | None = None
def client_session(self) -> aiohttp.ClientSession:
"""Create an aiohttp client session for this metrics transport."""
connector = (
aiohttp.UnixConnector(path=self.unix_socket_path)
if self.unix_socket_path is not None
else None
)
return aiohttp.ClientSession(connector=connector)
@pytest.fixture(scope="module")
def prometheus_output() -> str:
"""Return Prometheus exposition text after module-level metric registration."""
return generate_latest().decode()
class TestMetricObjects:
@@ -93,125 +69,6 @@ class TestMetricObjects:
),
],
)
def test_metric_in_output(self, name: str) -> None:
def test_metric_in_output(self, prometheus_output: str, name: str) -> None:
"""Each declared metric appears in the Prometheus text output."""
output = generate_latest().decode()
assert name in output, f"{name} not found in Prometheus output"
@pytest.fixture(
params=[
pytest.param("tcp", id="tcp"),
pytest.param("unix-socket", id="unix-socket"),
],
)
async def metrics_endpoint(
request: pytest.FixtureRequest,
tmp_path: Path,
) -> AsyncGenerator[MetricsEndpoint]:
"""Start a MetricsServer on each supported transport."""
transport = cast("MetricsTransport", request.param)
match transport:
case "tcp":
server = MetricsServer(TcpMetricsAddress("127.0.0.1", 0))
await server.start()
endpoint = MetricsEndpoint(f"http://127.0.0.1:{server.port}")
case "unix-socket":
socket_path = tmp_path / "metrics.sock"
server = MetricsServer(UnixMetricsAddress(str(socket_path)))
await server.start()
endpoint = MetricsEndpoint(
"http://crabstero",
unix_socket_path=str(socket_path),
)
try:
yield endpoint
finally:
await server.stop()
class TestMetricsServer:
"""HTTP server serves Prometheus metrics on /metrics."""
async def test_serves_metrics_endpoint(
self,
metrics_endpoint: MetricsEndpoint,
) -> None:
"""GET /metrics returns 200 with metric output containing our metrics."""
async with (
metrics_endpoint.client_session() as session,
session.get(f"{metrics_endpoint.base_url}/metrics") as resp,
):
assert resp.status == HTTPStatus.OK
body = await resp.text()
assert "crabstero_build_info" in body
async def test_non_metrics_path_returns_404(
self,
metrics_endpoint: MetricsEndpoint,
) -> None:
"""GET on an unknown path returns 404."""
async with (
metrics_endpoint.client_session() as session,
session.get(f"{metrics_endpoint.base_url}/notfound") as resp,
):
assert resp.status == HTTPStatus.NOT_FOUND
async def test_unix_socket_mode_is_applied(self, tmp_path: Path) -> None:
"""Configured Unix socket mode is applied after startup."""
socket_path = tmp_path / "metrics.sock"
server = MetricsServer(UnixMetricsAddress(str(socket_path), mode=0o666))
await server.start()
try:
assert socket_path.stat().st_mode & 0o777 == 0o666
finally:
await server.stop()
async def test_stale_unix_socket_path_is_recovered(
self,
tmp_path: Path,
) -> None:
"""Startup removes an abandoned Unix socket file left by a crash."""
socket_path = tmp_path / "metrics.sock"
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as stale_socket:
stale_socket.bind(str(socket_path))
server = MetricsServer(UnixMetricsAddress(str(socket_path), mode=0o666))
await server.start()
try:
assert socket_path.stat().st_mode & 0o777 == 0o666
connector = aiohttp.UnixConnector(path=str(socket_path))
async with (
aiohttp.ClientSession(connector=connector) as session,
session.get("http://crabstero/metrics") as resp,
):
assert resp.status == HTTPStatus.OK
body = await resp.text()
assert "crabstero_build_info" in body
finally:
await server.stop()
async def test_active_unix_socket_path_fails(
self,
tmp_path: Path,
) -> None:
"""Startup refuses to replace a Unix socket path that is still in use."""
socket_path = tmp_path / "metrics.sock"
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as active_socket:
active_socket.bind(str(socket_path))
active_socket.listen(1)
server = MetricsServer(UnixMetricsAddress(str(socket_path)))
with pytest.raises(OSError, match="already in use") as exc_info:
await server.start()
assert exc_info.value.errno == errno.EADDRINUSE
async def test_non_socket_unix_path_fails(self, tmp_path: Path) -> None:
"""Startup refuses to replace a non-socket path."""
socket_path = tmp_path / "metrics.sock"
socket_path.write_text("")
server = MetricsServer(UnixMetricsAddress(str(socket_path)))
with pytest.raises(FileExistsError):
await server.start()
assert name in prometheus_output, f"{name} not found in Prometheus output"
Generated
+14
View File
@@ -337,6 +337,7 @@ dev = [
{ name = "pytest-asyncio" },
{ name = "pytest-cov" },
{ name = "ruff" },
{ name = "simcord" },
]
[package.metadata]
@@ -357,6 +358,7 @@ dev = [
{ name = "pytest-asyncio", specifier = ">=1.4.0" },
{ name = "pytest-cov", specifier = ">=7.1.0" },
{ name = "ruff", specifier = ">=0.15.16" },
{ name = "simcord", specifier = "==1.0.1" },
]
[[package]]
@@ -922,6 +924,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/15/19/016553f86f207450aebebc2b2b5088d086b901cc8186c02ac4284db3bd88/ruff-0.15.16-py3-none-win_arm64.whl", hash = "sha256:8cd61783afb39638a7133ef0d2dfb1e91277593962f81b5a8423eb0b888a6121", size = 11134555, upload-time = "2026-06-04T16:33:00.136Z" },
]
[[package]]
name = "simcord"
version = "1.0.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "discord-py" },
]
sdist = { url = "https://files.pythonhosted.org/packages/3d/91/6c7d14a1c3421db4aa28ca2085d890861dcbb50ea06c2a146fb4f88f25de/simcord-1.0.1.tar.gz", hash = "sha256:6c15b379d40fe9a0aa049e9113169af7b4d13b48c09fe36042876ab66d263611", size = 351638, upload-time = "2026-06-17T23:39:03.403Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e3/1a/2f9db78d897a76174668da358fbdf90de76b792a2f5c5d4621b57b3c9beb/simcord-1.0.1-py3-none-any.whl", hash = "sha256:6cd326fb77b7edee0bab749633eee6610b4b947ab59fc9528e6b37f76d5a1c30", size = 120475, upload-time = "2026-06-17T23:39:01.931Z" },
]
[[package]]
name = "sortedcontainers"
version = "2.4.0"