Expanded integration coverage and enforced test categories.
This commit is contained in:
+7
-1
@@ -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
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
@@ -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."""
|
||||
@@ -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()
|
||||
+72
-40
@@ -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
|
||||
@@ -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."""
|
||||
@@ -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 == []
|
||||
@@ -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) == []
|
||||
@@ -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."""
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user